diff --git a/.cirrus.yml b/.cirrus.yml index 343701e3..311ccf05 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -16,6 +16,9 @@ task: - name: 'RPC functional tests' env: TEST_GROUP: tests/test_rpc.py + - name: 'Chain functional tests' + env: + TEST_GROUP: tests/test_chain.py cargo_registry_cache: folders: $CARGO_HOME/registry diff --git a/doc/API.md b/doc/API.md index 2d57697e..a9e089bc 100644 --- a/doc/API.md +++ b/doc/API.md @@ -45,7 +45,7 @@ This command does not take any parameter for now. | -------------------- | ------- | -------------------------------------------------------------------------------------------- | | `version` | string | Version following the [SimVer](http://www.simver.org/) format | | `network` | string | Answer can be `mainnet`, `testnet`, `regtest` | -| `blockheight` | integer | Current block height | +| `blockheight` | integer | The block height we are synced at. | | `sync` | float | The synchronization progress as percentage (`0 < sync < 1`) | | `descriptors` | object | Object with the name of the descriptor as key and the descriptor string as value | @@ -70,7 +70,7 @@ This command does not take any parameter for now. ### `listcoins` -List our current Unspent Transaction Outputs. +List all our transaction outputs, regardless of their state (unspent or not). #### Request @@ -81,11 +81,20 @@ This command does not take any parameter for now. #### Response -| Field | Type | Description | -| -------------- | ------------- | ---------------------------------------------------------------- | -| `amount` | int | Value of the UTxO in satoshis | -| `outpoint` | string | Transaction id and output index of this coin | -| `block_height` | int or null | Blockheight the transaction was confirmed at, or `null` | +| Field | Type | Description | +| -------------- | ------------- | ------------------------------------------------------------------------------------------------------------------ | +| `amount` | int | Value of the TxO in satoshis. | +| `outpoint` | string | Transaction id and output index of this coin. | +| `block_height` | int or null | Blockheight the transaction was confirmed at, or `null`. | +| `spend_info` | object | Information about the transaction spending this coin. See [Spending transaction info](#spending_transaction_info). | + + +##### Spending transaction info + +| Field | Type | Description | +| ---------- | ----------- | -------------------------------------------------------------- | +| `txid` | str | Spending transaction's id. | +| `height` | int or null | Block height the spending tx was included at, if confirmed. | ### `createspend` diff --git a/src/bitcoin/d/mod.rs b/src/bitcoin/d/mod.rs index 9704ff83..2bfe3a5e 100644 --- a/src/bitcoin/d/mod.rs +++ b/src/bitcoin/d/mod.rs @@ -651,6 +651,34 @@ impl BitcoinD { None } + + pub fn get_block_stats(&self, blockhash: bitcoin::BlockHash) -> BlockStats { + let res = self.make_node_request( + "getblockheader", + ¶ms!(Json::String(blockhash.to_string()),), + ); + let confirmations = res + .get("confirmations") + .and_then(Json::as_i64) + .expect("Invalid confirmations in `getblockheader` response: not an i64") + as i32; + let previous_blockhash = res + .get("previousblockhash") + .and_then(Json::as_str) + .and_then(|s| bitcoin::BlockHash::from_str(s).ok()) + .expect("Invalid previousblockhash in `getblockheader` response"); + let height = res + .get("height") + .and_then(Json::as_i64) + .expect("Invalid height in `getblockheader` response: not an u32") + as i32; + BlockStats { + confirmations, + previous_blockhash, + height, + blockhash, + } + } } // Bitcoind uses a guess for the value of verificationprogress. It will eventually get to // be 1, and we want to be less conservative. @@ -779,3 +807,11 @@ impl From for GetTxRes { } } } + +#[derive(Debug, Clone)] +pub struct BlockStats { + pub confirmations: i32, + pub previous_blockhash: bitcoin::BlockHash, + pub blockhash: bitcoin::BlockHash, + pub height: i32, +} diff --git a/src/bitcoin/mod.rs b/src/bitcoin/mod.rs index f4c2df81..5f9a7289 100644 --- a/src/bitcoin/mod.rs +++ b/src/bitcoin/mod.rs @@ -6,10 +6,9 @@ pub mod poller; use d::LSBlockEntry; -use std::collections::HashMap; -use std::sync; +use std::{collections::HashMap, fmt, sync}; -use miniscript::bitcoin::{self, hashes::Hash}; +use miniscript::bitcoin; /// Information about the best block in the chain #[derive(Debug, Clone, Eq, PartialEq, Copy)] @@ -18,6 +17,12 @@ pub struct BlockChainTip { pub height: i32, } +impl fmt::Display for BlockChainTip { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "({},{})", self.height, self.hash) + } +} + /// Our Bitcoin backend. pub trait BitcoinInterface: Send { fn genesis_block(&self) -> BlockChainTip; @@ -51,7 +56,10 @@ pub trait BitcoinInterface: Send { fn spent_coins( &self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)], - ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)>; + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)>; + + /// Get the common ancestor between the Bitcoin backend's tip and the given tip. + fn common_ancestor(&self, tip: &BlockChainTip) -> BlockChainTip; } impl BitcoinInterface for d::BitcoinD { @@ -136,10 +144,10 @@ impl BitcoinInterface for d::BitcoinD { } else { // TODO: better handling of this edge case. log::error!( - "Could not get spender of '{}'. Using a dummy spending txid.", + "Could not get spender of '{}'. Not reporting it as spending.", op ); - bitcoin::Txid::from_slice(&[0; 32][..]).unwrap() + continue; }; spent.push((*op, spending_txid)); @@ -152,7 +160,7 @@ impl BitcoinInterface for d::BitcoinD { fn spent_coins( &self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)], - ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)> { + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> { let mut spent = Vec::with_capacity(outpoints.len()); let mut cache: HashMap> = HashMap::new(); @@ -171,10 +179,15 @@ impl BitcoinInterface for d::BitcoinD { let mut txs_to_cache: Vec<(bitcoin::Txid, Option)> = Vec::new(); if let Some(tx) = tx { - if let Some(block_time) = tx.block_time { + if let Some(block_height) = tx.block_height { // TODO: make both block time and height under the same Option. assert!(tx.block_height.is_some()); - spent.push((*op, *txid, block_time)) + spent.push(( + *op, + *txid, + block_height, + tx.block_time.expect("Confirmed tx."), + )); } else if !tx.conflicting_txs.is_empty() { for txid in &tx.conflicting_txs { let tx: Option<&d::GetTxRes> = match cache.get(txid) { @@ -187,13 +200,12 @@ impl BitcoinInterface for d::BitcoinD { }; if let Some(tx) = tx { if let Some(block_height) = tx.block_height { - if block_height > 1 { - spent.push(( - *op, - *txid, - tx.block_time.expect("Spend is confirmed"), - )) - } + spent.push(( + *op, + *txid, + block_height, + tx.block_time.expect("Spend is confirmed"), + )) } } } @@ -207,6 +219,21 @@ impl BitcoinInterface for d::BitcoinD { spent } + + fn common_ancestor(&self, tip: &BlockChainTip) -> BlockChainTip { + let mut stats = self.get_block_stats(tip.hash); + let mut ancestor = *tip; + + while stats.confirmations == -1 { + stats = self.get_block_stats(stats.previous_blockhash); + ancestor = BlockChainTip { + hash: stats.blockhash, + height: stats.height, + }; + } + + ancestor + } } // FIXME: do we need to repeat the entire trait implemenation? Isn't there a nicer way? @@ -248,9 +275,13 @@ impl BitcoinInterface for sync::Arc> fn spent_coins( &self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)], - ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)> { + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> { self.lock().unwrap().spent_coins(outpoints) } + + fn common_ancestor(&self, tip: &BlockChainTip) -> BlockChainTip { + self.lock().unwrap().common_ancestor(tip) + } } // FIXME: We could avoid this type (and all the conversions entailing allocations) if bitcoind diff --git a/src/bitcoin/poller/looper.rs b/src/bitcoin/poller/looper.rs index 2ade7202..25aa39ae 100644 --- a/src/bitcoin/poller/looper.rs +++ b/src/bitcoin/poller/looper.rs @@ -15,7 +15,7 @@ struct UpdatedCoins { pub received: Vec, pub confirmed: Vec<(bitcoin::OutPoint, i32, u32)>, pub spending: Vec<(bitcoin::OutPoint, bitcoin::Txid)>, - pub spent: Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)>, + pub spent: Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)>, } // Update the state of our coins. There may be new unspent, and existing ones may become confirmed @@ -27,8 +27,10 @@ fn update_coins( db_conn: &mut Box, previous_tip: &BlockChainTip, ) -> UpdatedCoins { + let curr_coins = db_conn.coins(); + log::debug!("Current coins: {:?}", curr_coins); + // Start by fetching newly received coins. - let curr_coins = db_conn.unspent_coins(); let mut received = Vec::new(); for utxo in bit.received_coins(previous_tip) { if let Some(derivation_index) = db_conn.derivation_index_by_address(&utxo.address) { @@ -43,7 +45,7 @@ fn update_coins( block_height: None, block_time: None, spend_txid: None, - spent_at: None, + spend_block: None, }; received.push(coin); } @@ -55,6 +57,7 @@ fn update_coins( ); } } + log::debug!("Newly received coins: {:?}", received); // We need to take the newly received ones into account as well, as they may have been // confirmed within the previous tip and the current one, and we may not poll this chunk of the @@ -71,6 +74,7 @@ fn update_coins( }) .collect(); let confirmed = bit.confirmed_coins(&to_be_confirmed); + log::debug!("Newly confirmed coins: {:?}", confirmed); // We need to take the newly received ones into account as well, as they may have been // spent within the previous tip and the current one, and we may not poll this chunk of the @@ -87,6 +91,7 @@ fn update_coins( }) .collect(); let spending = bit.spending_coins(&to_be_spent); + log::debug!("Newly spending coins: {:?}", spending); // Mark coins in a spending state whose Spend transaction was confirmed as such. Note we // need to take into account the freshly marked as spending coins as well, as their spend @@ -99,6 +104,7 @@ fn update_coins( .chain(spending.iter().cloned()) .collect(); let spent = bit.spent_coins(spending_coins.as_slice()); + log::debug!("Newly spent coins: {:?}", spent); UpdatedCoins { received, @@ -108,25 +114,44 @@ fn update_coins( } } +#[derive(Debug, Clone, Copy)] +enum TipUpdate { + // The best block is still the same as in the previous poll. + Same, + // There is a new best block that extends the same chain. + Progress(BlockChainTip), + // There is a new best block that extends a chain which does not contain our former tip. + Reorged(BlockChainTip), +} + // Returns the new block chain tip, if it changed. -fn new_tip(bit: &impl BitcoinInterface, current_tip: &BlockChainTip) -> Option { +fn new_tip(bit: &impl BitcoinInterface, current_tip: &BlockChainTip) -> TipUpdate { let bitcoin_tip = bit.chain_tip(); // If the tip didn't change, there is nothing to update. if current_tip == &bitcoin_tip { - return None; + return TipUpdate::Same; } if bitcoin_tip.height > current_tip.height { // Make sure we are on the same chain. if bit.is_in_chain(current_tip) { // All good, we just moved forward. - return Some(bitcoin_tip); + return TipUpdate::Progress(bitcoin_tip); } } - // TODO: reorg handling. - None + // Either the new height is lower or the same but the block hash differs. There was a + // block chain re-organisation. Find the common ancestor between our current chain and + // the new chain and return that. The caller will take care of rewinding our state. + log::info!("Block chain reorganization detected. Looking for common ancestor."); + let common_ancestor = bit.common_ancestor(current_tip); + log::info!( + "Common ancestor found: '{}'. Starting rescan from there. Old tip was '{}'.", + common_ancestor, + current_tip + ); + TipUpdate::Reorged(common_ancestor) } fn updates(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) { @@ -134,8 +159,17 @@ fn updates(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) { // Check if there was a new block before updating ourselves. let current_tip = db_conn.chain_tip().expect("Always set at first startup"); - let new_tip = new_tip(bit, ¤t_tip); - let latest_tip = new_tip.unwrap_or(current_tip); + let latest_tip = match new_tip(bit, ¤t_tip) { + TipUpdate::Same => current_tip, + TipUpdate::Progress(new_tip) => new_tip, + TipUpdate::Reorged(new_tip) => { + // The block chain was reorganized. Rollback our state down to the common ancestor + // between our former chain and the new one, then restart fresh. + db_conn.rollback_tip(&new_tip); + log::info!("Tip was rolled back to '{}'.", new_tip); + return updates(bit, db); + } + }; // Then check the state of our coins. Do it even if the tip did not change since last poll, as // we may have unconfirmed transactions. @@ -154,9 +188,12 @@ fn updates(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) { db_conn.confirm_coins(&updated_coins.confirmed); db_conn.spend_coins(&updated_coins.spending); db_conn.confirm_spend(&updated_coins.spent); - if let Some(tip) = new_tip { - db_conn.update_tip(&tip); + if latest_tip != current_tip { + db_conn.update_tip(&latest_tip); + log::debug!("New tip: '{}'", latest_tip); } + + log::debug!("Updates done."); } // If the database chain tip is NULL (first startup), initialize it. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c94e0e0a..a52e85f4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -167,10 +167,13 @@ impl DaemonControl { impl DaemonControl { /// Get information about the current state of the daemon pub fn get_info(&self) -> GetInfoResult { + let mut db_conn = self.db.connection(); + + let blockheight = db_conn.chain_tip().map(|tip| tip.height).unwrap_or(0); GetInfoResult { version: VERSION.to_string(), network: self.config.bitcoin_config.network, - blockheight: self.bitcoin.chain_tip().height, + blockheight, sync: self.bitcoin.sync_progress(), descriptors: GetInfoDescriptors { main: self.config.main_descriptor.clone(), @@ -193,11 +196,11 @@ impl DaemonControl { GetAddressResult { address } } - /// Get a list of all currently unspent coins. + /// Get a list of all known coins. pub fn list_coins(&self) -> ListCoinsResult { let mut db_conn = self.db.connection(); let coins: Vec = db_conn - .unspent_coins() + .coins() // Can't use into_values as of Rust 1.48 .into_iter() .map(|(_, coin)| { @@ -205,12 +208,19 @@ impl DaemonControl { amount, outpoint, block_height, + spend_txid, + spend_block, .. } = coin; + let spend_info = spend_txid.map(|txid| LCSpendInfo { + txid, + height: spend_block.map(|b| b.height), + }); ListCoinsEntry { amount, outpoint, block_height, + spend_info, } }) .collect(); @@ -458,7 +468,14 @@ pub struct GetAddressResult { pub address: bitcoin::Address, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct LCSpendInfo { + pub txid: bitcoin::Txid, + /// The block height this spending transaction was confirmed at. + pub height: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct ListCoinsEntry { #[serde( serialize_with = "ser_amount", @@ -467,6 +484,8 @@ pub struct ListCoinsEntry { pub amount: bitcoin::Amount, pub outpoint: bitcoin::OutPoint, pub block_height: Option, + /// Information about the transaction spending this coin. + pub spend_info: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -572,7 +591,7 @@ mod tests { amount: bitcoin::Amount::from_sat(100_000), derivation_index: bip32::ChildNumber::from(13), spend_txid: None, - spent_at: None, + spend_block: None, }]); let res = control.create_spend(&[dummy_op], &destinations, 1).unwrap(); let tx = res.psbt.global.unsigned_tx; @@ -666,7 +685,7 @@ mod tests { amount: bitcoin::Amount::from_sat(100_000), derivation_index: bip32::ChildNumber::from(13), spend_txid: None, - spent_at: None, + spend_block: None, }, Coin { outpoint: dummy_op_b, @@ -675,7 +694,7 @@ mod tests { amount: bitcoin::Amount::from_sat(115_680), derivation_index: bip32::ChildNumber::from(34), spend_txid: None, - spent_at: None, + spend_block: None, }, ]); diff --git a/src/database/mod.rs b/src/database/mod.rs index 6cc0c300..c9e2d88b 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -6,7 +6,7 @@ pub mod sqlite; use crate::{ bitcoin::BlockChainTip, database::sqlite::{ - schema::{DbCoin, DbTip}, + schema::{DbCoin, DbSpendBlock, DbTip}, SqliteConn, SqliteDb, }, }; @@ -54,8 +54,8 @@ pub trait DatabaseConnection { address: &bitcoin::Address, ) -> Option; - /// Get all UTxOs. - fn unspent_coins(&mut self) -> HashMap; + /// Get all our coins, past or present, spent or not. + fn coins(&mut self) -> HashMap; /// List coins that are being spent and whose spending transaction is still unconfirmed. fn list_spending_coins(&mut self) -> HashMap; @@ -70,7 +70,7 @@ pub trait DatabaseConnection { fn spend_coins(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]); /// Mark a set of coins as spent by a specified txid at a specified block time. - fn confirm_spend(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, u32)]); + fn confirm_spend(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, i32, u32)]); /// Get specific coins from the database. fn coins_by_outpoints( @@ -88,6 +88,9 @@ pub trait DatabaseConnection { /// Delete a Spend transaction from database. fn delete_spend(&mut self, txid: &bitcoin::Txid); + + /// Mark the given tip as the new best seen block. Update stored data accordingly. + fn rollback_tip(&mut self, new_tip: &BlockChainTip); } impl DatabaseConnection for SqliteConn { @@ -118,8 +121,8 @@ impl DatabaseConnection for SqliteConn { self.increment_derivation_index(secp) } - fn unspent_coins(&mut self) -> HashMap { - self.unspent_coins() + fn coins(&mut self) -> HashMap { + self.coins() .into_iter() .map(|db_coin| (db_coin.outpoint, db_coin.into())) .collect() @@ -144,7 +147,7 @@ impl DatabaseConnection for SqliteConn { self.spend_coins(outpoints) } - fn confirm_spend<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, u32)]) { + fn confirm_spend<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, i32, u32)]) { self.confirm_spend(outpoints) } @@ -184,9 +187,28 @@ impl DatabaseConnection for SqliteConn { fn delete_spend(&mut self, txid: &bitcoin::Txid) { self.delete_spend(txid) } + + fn rollback_tip(&mut self, new_tip: &BlockChainTip) { + self.rollback_tip(new_tip) + } } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SpendBlock { + pub height: i32, + pub time: u32, +} + +impl From for SpendBlock { + fn from(b: DbSpendBlock) -> SpendBlock { + SpendBlock { + height: b.height, + time: b.time, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Coin { pub outpoint: bitcoin::OutPoint, pub block_height: Option, @@ -194,7 +216,7 @@ pub struct Coin { pub amount: bitcoin::Amount, pub derivation_index: bip32::ChildNumber, pub spend_txid: Option, - pub spent_at: Option, + pub spend_block: Option, } impl std::convert::From for Coin { @@ -206,7 +228,7 @@ impl std::convert::From for Coin { amount, derivation_index, spend_txid, - spent_at, + spend_block, .. } = db_coin; Coin { @@ -216,7 +238,7 @@ impl std::convert::From for Coin { amount, derivation_index, spend_txid, - spent_at, + spend_block: spend_block.map(SpendBlock::from), } } } diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index eb24d259..1f29842d 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -252,11 +252,11 @@ impl SqliteConn { .expect("Database must be available") } - /// Get all UTxOs. - pub fn unspent_coins(&mut self) -> Vec { + /// Get all the coins from DB. + pub fn coins(&mut self) -> Vec { db_query( &mut self.conn, - "SELECT * FROM coins WHERE spend_txid is NULL", + "SELECT * FROM coins", rusqlite::params![], |row| row.try_into(), ) @@ -267,7 +267,7 @@ impl SqliteConn { pub fn list_spending_coins(&mut self) -> Vec { db_query( &mut self.conn, - "SELECT * FROM coins WHERE spend_txid IS NOT NULL AND spent_at IS NULL", + "SELECT * FROM coins WHERE spend_txid IS NOT NULL AND spend_block_time IS NULL", rusqlite::params![], |row| row.try_into(), ) @@ -334,17 +334,19 @@ impl SqliteConn { .expect("Database must be available") } - /// Mark a set of coins as spent. + /// Mark the Spend transaction of a given set of coins as being confirmed at a given + /// block. pub fn confirm_spend<'a>( &mut self, - outpoints: impl IntoIterator, + outpoints: impl IntoIterator, ) { db_exec(&mut self.conn, |db_tx| { - for (outpoint, spend_txid, time) in outpoints { + for (outpoint, spend_txid, height, time) in outpoints { db_tx.execute( - "UPDATE coins SET spend_txid = ?1, spent_at = ?2 WHERE txid = ?3 AND vout = ?4", + "UPDATE coins SET spend_txid = ?1, spend_block_height = ?2, spend_block_time = ?3 WHERE txid = ?4 AND vout = ?5", rusqlite::params![ spend_txid.to_vec(), + height, time, outpoint.txid.to_vec(), outpoint.vout, @@ -437,13 +439,47 @@ impl SqliteConn { }) .expect("Db must not fail"); } + + /// Unconfirm all data that was marked as being confirmed *after* the given chain + /// tip, and set it as our new best block seen. + /// + /// This includes: + /// - Coins + /// - Spending transactions confirmation + /// - Tip + /// + /// This will have to be updated if we are to add new fields based on block data + /// in the database eventually. + pub fn rollback_tip(&mut self, new_tip: &BlockChainTip) { + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "UPDATE coins SET blockheight = NULL, blocktime = NULL, spend_block_height = NULL, spend_block_time = NULL WHERE blockheight > ?1", + rusqlite::params![new_tip.height], + )?; + db_tx.execute( + "UPDATE coins SET spend_block_height = NULL, spend_block_time = NULL WHERE spend_block_height > ?1", + rusqlite::params![new_tip.height], + )?; + db_tx.execute( + "UPDATE tip SET blockheight = (?1), blockhash = (?2)", + rusqlite::params![new_tip.height, new_tip.hash.to_vec()], + )?; + Ok(()) + }) + .expect("Db must not fail"); + } } #[cfg(test)] mod tests { use super::*; + use crate::database::SpendBlock; use crate::testutils::*; - use std::{collections::HashSet, fs, path, str::FromStr}; + use std::{ + collections::{HashMap, HashSet}, + fs, path, + str::FromStr, + }; use bitcoin::{hashes::Hash, util::bip32}; @@ -553,7 +589,7 @@ mod tests { let mut conn = db.connection().unwrap(); // Necessarily empty at first. - assert!(conn.unspent_coins().is_empty()); + assert!(conn.coins().is_empty()); // Add one, we'll get it. let coin_a = Coin { @@ -566,10 +602,10 @@ mod tests { amount: bitcoin::Amount::from_sat(98765), derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), spend_txid: None, - spent_at: None, + spend_block: None, }; - conn.new_unspent_coins(&[coin_a.clone()]); // On 1.48, arrays aren't IntoIterator - assert_eq!(conn.unspent_coins()[0].outpoint, coin_a.outpoint); + conn.new_unspent_coins(&[coin_a]); + assert_eq!(conn.coins()[0].outpoint, coin_a.outpoint); // We can query it by its outpoint let coins = conn.db_coins(&[coin_a.outpoint]); @@ -587,14 +623,11 @@ mod tests { amount: bitcoin::Amount::from_sat(1111), derivation_index: bip32::ChildNumber::from_normal_idx(103).unwrap(), spend_txid: None, - spent_at: None, + spend_block: None, }; - conn.new_unspent_coins(&[coin_b.clone()]); - let outpoints: HashSet = conn - .unspent_coins() - .into_iter() - .map(|c| c.outpoint) - .collect(); + conn.new_unspent_coins(&[coin_b]); + let outpoints: HashSet = + conn.coins().into_iter().map(|c| c.outpoint).collect(); assert!(outpoints.contains(&coin_a.outpoint)); assert!(outpoints.contains(&coin_b.outpoint)); @@ -614,24 +647,24 @@ mod tests { let height = 174500; let time = 174500; conn.confirm_coins(&[(coin_a.outpoint, height, time)]); - let coins = conn.unspent_coins(); + let coins = conn.coins(); assert_eq!(coins[0].block_height, Some(height)); assert_eq!(coins[0].block_time, Some(time)); assert!(coins[1].block_height.is_none()); assert!(coins[1].block_time.is_none()); - // Now if we spend one, we'll only get the other one. + // Now if we spend one, it'll be marked as such. conn.spend_coins(&[( coin_a.outpoint, bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(), )]); - let outpoints: HashSet = conn - .unspent_coins() - .into_iter() - .map(|c| c.outpoint) - .collect(); - assert!(!outpoints.contains(&coin_a.outpoint)); - assert!(outpoints.contains(&coin_b.outpoint)); + let coins_map: HashMap = + conn.coins().into_iter().map(|c| (c.outpoint, c)).collect(); + assert!(coins_map + .get(&coin_a.outpoint) + .unwrap() + .spend_txid + .is_some()); let outpoints: HashSet = conn .list_spending_coins() @@ -641,10 +674,13 @@ mod tests { assert!(outpoints.contains(&coin_a.outpoint)); // Now if we confirm the spend. + let height = 128_097; + let time = 3_000_000; conn.confirm_spend(&[( coin_a.outpoint, bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(), - 3, + height, + time, )]); // the coin is not in a spending state. let outpoints: HashSet = conn @@ -657,6 +693,12 @@ mod tests { // Both are still in DB let coins = conn.db_coins(&[coin_a.outpoint, coin_b.outpoint]); assert_eq!(coins.len(), 2); + + // The confirmed one contains the right time and block height + let coin = conn.db_coins(&[coin_a.outpoint]).pop().unwrap(); + assert!(coin.spend_block.is_some()); + assert_eq!(coin.spend_block.as_ref().unwrap().time, time); + assert_eq!(coin.spend_block.unwrap().height, height); } fs::remove_dir_all(&tmp_dir).unwrap(); @@ -700,4 +742,187 @@ mod tests { fs::remove_dir_all(&tmp_dir).unwrap(); } + + #[test] + fn sqlite_tip_rollback() { + let (tmp_dir, _, _, db) = dummy_db(); + + { + let mut conn = db.connection().unwrap(); + + let old_tip = BlockChainTip { + hash: bitcoin::BlockHash::from_str( + "00000000000000000004f43b5e743757939082170673d27a5a5130e0eb238832", + ) + .unwrap(), + height: 200_000, + }; + conn.update_tip(&old_tip); + + // 5 coins: + // - One unconfirmed + // - One confirmed before the rollback height + // - One confirmed before the rollback height but spent after + // - One confirmed after the rollback height + // - One spent after the rollback height + let coins = [ + Coin { + outpoint: bitcoin::OutPoint::from_str( + "6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1", + ) + .unwrap(), + block_height: None, + block_time: None, + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), + spend_txid: None, + spend_block: None, + }, + Coin { + outpoint: bitcoin::OutPoint::from_str( + "c449539458c60bee6c0d8905ba1dadb20b9187b82045d306a408b894cea492b0:2", + ) + .unwrap(), + block_height: Some(101_095), + block_time: Some(1_111_899), + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(100).unwrap(), + spend_txid: None, + spend_block: None, + }, + Coin { + outpoint: bitcoin::OutPoint::from_str( + "f0801fd9ca8bca0624c230ab422b2e2c4c8dc995e4e1dbc6412510959cce1e4f:3", + ) + .unwrap(), + block_height: Some(101_099), + block_time: Some(1_121_899), + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(1000).unwrap(), + spend_txid: Some( + bitcoin::Txid::from_str( + "0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7", + ) + .unwrap(), + ), + spend_block: Some(SpendBlock { + height: 101_199, + time: 1_231_678, + }), + }, + Coin { + outpoint: bitcoin::OutPoint::from_str( + "19f56e65069f0a7a3bfb00c6a7085cc0669e03e91befeca1ee9891c9e737b2fb:4", + ) + .unwrap(), + block_height: Some(101_100), + block_time: Some(1_131_899), + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(10000).unwrap(), + spend_txid: None, + spend_block: None, + }, + Coin { + outpoint: bitcoin::OutPoint::from_str( + "ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9:5", + ) + .unwrap(), + block_height: Some(101_102), + block_time: Some(1_134_899), + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(100000).unwrap(), + spend_txid: Some( + bitcoin::Txid::from_str( + "7477017f992cdc7ba08acafb77cb3b5bc0f42ac340d3e1e1da0785bdda20d5f6", + ) + .unwrap(), + ), + spend_block: Some(SpendBlock { + height: 101_105, + time: 1_201_678, + }), + }, + ]; + conn.new_unspent_coins(&coins); + conn.confirm_coins( + &coins + .iter() + .filter_map(|c| { + c.block_height + .map(|b| (c.outpoint, b, c.block_time.unwrap())) + }) + .collect::>(), + ); + conn.confirm_spend( + &coins + .iter() + .filter_map(|c| { + c.spend_block + .as_ref() + .map(|b| (c.outpoint, c.spend_txid.unwrap(), b.height, b.time)) + }) + .collect::>(), + ); + let mut db_coins = conn + .db_coins( + &coins + .iter() + .map(|c| c.outpoint) + .collect::>(), + ) + .into_iter() + .map(Coin::from) + .collect::>(); + db_coins.sort_by(|c1, c2| c1.outpoint.vout.cmp(&c2.outpoint.vout)); + assert_eq!(&db_coins[..], &coins[..]); + + // Now that everything is settled, reorg to a previous height. + let new_tip = BlockChainTip { + hash: bitcoin::BlockHash::from_str( + "000000000000000000016440c591da27679abfa53ef44d45b016640dbd04e126", + ) + .unwrap(), + height: 101_099, + }; + conn.rollback_tip(&new_tip); + + // The tip got updated + let new_db_tip = conn.db_tip(); + assert_eq!(new_db_tip.block_height.unwrap(), new_tip.height); + assert_eq!(new_db_tip.block_hash.unwrap(), new_tip.hash); + + // And so were the coins + let db_coins = conn + .db_coins( + &coins + .iter() + .map(|c| c.outpoint) + .collect::>(), + ) + .into_iter() + .map(|c| (c.outpoint, Coin::from(c))) + .collect::>(); + // The first coin is unchanged + assert_eq!(db_coins[&coins[0].outpoint], coins[0]); + // Same for the second one + assert_eq!(db_coins[&coins[1].outpoint], coins[1]); + // The third one got its spend confirmation info wiped, but only that + let mut coin = coins[2]; + coin.spend_block = None; + assert_eq!(db_coins[&coins[2].outpoint], coin); + // The fourth one got its own confirmation info wiped + let mut coin = coins[3]; + coin.block_height = None; + coin.block_time = None; + assert_eq!(db_coins[&coins[3].outpoint], coin); + // The fourth one got both is own confirmation and spend confirmation info wiped + let mut coin = coins[4]; + coin.block_height = None; + coin.block_time = None; + coin.spend_block = None; + assert_eq!(db_coins[&coins[4].outpoint], coin); + } + + fs::remove_dir_all(&tmp_dir).unwrap(); + } } diff --git a/src/database/sqlite/schema.rs b/src/database/sqlite/schema.rs index 8a97696c..58b5f8b5 100644 --- a/src/database/sqlite/schema.rs +++ b/src/database/sqlite/schema.rs @@ -30,7 +30,11 @@ CREATE TABLE wallets ( deposit_derivation_index INTEGER NOT NULL ); -/* Our (U)TxOs. */ +/* Our (U)TxOs. + * + * The 'spend_block_height' and 'spend_block.time' are only present if the spending + * transaction for this coin exists and was confirmed. + */ CREATE TABLE coins ( id INTEGER PRIMARY KEY NOT NULL, wallet_id INTEGER NOT NULL, @@ -41,8 +45,8 @@ CREATE TABLE coins ( amount_sat INTEGER NOT NULL, derivation_index INTEGER NOT NULL, spend_txid BLOB, - /* Time of the block containing the transaction spending the coin, NULL if not confirmed */ - spent_at INTEGER, + spend_block_height INTEGER, + spend_block_time INTEGER, UNIQUE (txid, vout), FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON UPDATE RESTRICT @@ -126,7 +130,13 @@ impl TryFrom<&rusqlite::Row<'_>> for DbWallet { } } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DbSpendBlock { + pub height: i32, + pub time: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DbCoin { pub id: i64, pub wallet_id: i64, @@ -136,7 +146,7 @@ pub struct DbCoin { pub amount: bitcoin::Amount, pub derivation_index: bip32::ChildNumber, pub spend_txid: Option, - pub spent_at: Option, + pub spend_block: Option, } impl TryFrom<&rusqlite::Row<'_>> for DbCoin { @@ -161,7 +171,13 @@ impl TryFrom<&rusqlite::Row<'_>> for DbCoin { let spend_txid: Option> = row.get(8)?; let spend_txid = spend_txid.map(|txid| encode::deserialize(&txid).expect("We only store valid txids")); - let spent_at = row.get(9)?; + let spend_height: Option = row.get(9)?; + let spend_time: Option = row.get(10)?; + assert_eq!(spend_height.is_none(), spend_time.is_none()); + let spend_block = spend_height.map(|height| DbSpendBlock { + height, + time: spend_time.expect("Must be there if height is"), + }); Ok(DbCoin { id, @@ -172,7 +188,7 @@ impl TryFrom<&rusqlite::Row<'_>> for DbCoin { amount, derivation_index, spend_txid, - spent_at, + spend_block, }) } } diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index 1d480e46..740ed36a 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -54,7 +54,7 @@ fn update_spend(control: &DaemonControl, params: Params) -> Result Result Vec<(bitcoin::OutPoint, bitcoin::Txid, u32)> { + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> { Vec::new() } + + fn common_ancestor(&self, _: &BlockChainTip) -> BlockChainTip { + todo!() + } } pub struct DummyDb { @@ -120,7 +124,7 @@ impl DatabaseConnection for DummyDbConn { self.db.write().unwrap().curr_index = next_index; } - fn unspent_coins(&mut self) -> HashMap { + fn coins(&mut self) -> HashMap { self.db.read().unwrap().coins.clone() } @@ -128,7 +132,7 @@ impl DatabaseConnection for DummyDbConn { let mut result = HashMap::new(); for (k, v) in self.db.read().unwrap().coins.iter() { if v.spend_txid.is_some() { - result.insert(*k, v.clone()); + result.insert(*k, *v); } } result @@ -136,11 +140,7 @@ impl DatabaseConnection for DummyDbConn { fn new_unspent_coins<'a>(&mut self, coins: &[Coin]) { for coin in coins { - self.db - .write() - .unwrap() - .coins - .insert(coin.outpoint, coin.clone()); + self.db.write().unwrap().coins.insert(coin.outpoint, *coin); } } @@ -160,19 +160,22 @@ impl DatabaseConnection for DummyDbConn { let mut db = self.db.write().unwrap(); let spent = &mut db.coins.get_mut(op).unwrap(); assert!(spent.spend_txid.is_none()); - assert!(spent.spent_at.is_none()); + assert!(spent.spend_block.is_none()); spent.spend_txid = Some(*spend_txid); } } - fn confirm_spend<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, u32)]) { - for (op, spend_txid, time) in outpoints { + fn confirm_spend<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid, i32, u32)]) { + for (op, spend_txid, height, time) in outpoints { let mut db = self.db.write().unwrap(); let spent = &mut db.coins.get_mut(op).unwrap(); assert!(spent.spend_txid.is_some()); - assert!(spent.spent_at.is_none()); + assert!(spent.spend_block.is_none()); spent.spend_txid = Some(*spend_txid); - spent.spent_at = Some(*time); + spent.spend_block = Some(SpendBlock { + height: *height, + time: *time, + }); } } @@ -221,6 +224,10 @@ impl DatabaseConnection for DummyDbConn { fn delete_spend(&mut self, txid: &bitcoin::Txid) { self.db.write().unwrap().spend_txs.remove(txid); } + + fn rollback_tip(&mut self, _: &BlockChainTip) { + todo!() + } } pub struct DummyMinisafe { diff --git a/tests/test_chain.py b/tests/test_chain.py new file mode 100644 index 00000000..96eb06ca --- /dev/null +++ b/tests/test_chain.py @@ -0,0 +1,168 @@ +from fixtures import * +from test_framework.utils import wait_for, get_txid, spend_coins + + +def get_coin(minisafed, outpoint_or_txid): + return next( + c + for c in minisafed.rpc.listcoins()["coins"] + if outpoint_or_txid in c["outpoint"] + ) + + +def test_reorg_detection(minisafed, bitcoind): + """Test we detect block chain reorganization under various conditions.""" + initial_height = bitcoind.rpc.getblockcount() + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height) + + # Re-mine the last block. We should detect it as a reorg. + bitcoind.invalidate_remine(initial_height) + minisafed.wait_for_logs( + ["Block chain reorganization detected.", "Tip was rolled back."] + ) + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height) + + # Same if we re-mine the next-to-last block. + bitcoind.invalidate_remine(initial_height - 1) + minisafed.wait_for_logs( + ["Block chain reorganization detected.", "Tip was rolled back."] + ) + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height) + + # Same if we re-mine a deep block. + bitcoind.invalidate_remine(initial_height - 50) + minisafed.wait_for_logs( + ["Block chain reorganization detected.", "Tip was rolled back."] + ) + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height) + + # Same if the new chain is longer. + bitcoind.simple_reorg(initial_height - 10, shift=20) + minisafed.wait_for_logs( + ["Block chain reorganization detected.", "Tip was rolled back."] + ) + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height + 10) + + +def test_reorg_exclusion(minisafed, bitcoind): + """Test the unconfirmation by a reorg of a coin in various states.""" + initial_height = bitcoind.rpc.getblockcount() + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height) + + # A confirmed received coin + addr = minisafed.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, 1) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) == 1) + coin_a = minisafed.rpc.listcoins()["coins"][0] + + # A confirmed and 'spending' (unconfirmed spend) coin + addr = minisafed.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, 2) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) == 2) + coin_b = get_coin(minisafed, txid) + b_spend_tx = spend_coins(minisafed, bitcoind, [coin_b]) + + # A confirmed and spent coin + addr = minisafed.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, 3) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) == 3) + coin_c = get_coin(minisafed, txid) + c_spend_tx = spend_coins(minisafed, bitcoind, [coin_c]) + bitcoind.generate_block(1, wait_for_mempool=1) + + # Reorg the chain down to the initial height, excluding all transactions. + current_height = bitcoind.rpc.getblockcount() + bitcoind.simple_reorg(initial_height, shift=-1) + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == current_height + 1) + + # They must all be marked as unconfirmed. + new_coin_a = get_coin(minisafed, coin_a["outpoint"]) + assert new_coin_a["block_height"] is None + new_coin_b = get_coin(minisafed, coin_b["outpoint"]) + assert new_coin_b["block_height"] is None + new_coin_c = get_coin(minisafed, coin_c["outpoint"]) + assert new_coin_c["block_height"] is None + + # And if we now confirm everything, they'll be marked as such. The one that was 'spending' + # will now be spent (its spending transaction will be confirmed) and the one that was spent + # will be marked as such. + deposit_txids = [c["outpoint"][:-2] for c in (coin_a, coin_b, coin_c)] + for txid in deposit_txids: + tx = bitcoind.rpc.gettransaction(txid)["hex"] + bitcoind.rpc.sendrawtransaction(tx) + bitcoind.rpc.sendrawtransaction(b_spend_tx) + bitcoind.rpc.sendrawtransaction(c_spend_tx) + bitcoind.generate_block(1, wait_for_mempool=5) + new_height = bitcoind.rpc.getblockcount() + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == new_height) + assert all( + c["block_height"] == new_height for c in minisafed.rpc.listcoins()["coins"] + ), (minisafed.rpc.listcoins()["coins"], new_height) + new_coin_b = next( + c + for c in minisafed.rpc.listcoins()["coins"] + if coin_b["outpoint"] == c["outpoint"] + ) + b_spend_txid = get_txid(b_spend_tx) + assert new_coin_b["spend_info"]["txid"] == b_spend_txid + assert new_coin_b["spend_info"]["height"] == new_height + new_coin_c = next( + c + for c in minisafed.rpc.listcoins()["coins"] + if coin_c["outpoint"] == c["outpoint"] + ) + c_spend_txid = get_txid(c_spend_tx) + assert new_coin_c["spend_info"]["txid"] == c_spend_txid + assert new_coin_c["spend_info"]["height"] == new_height + + # TODO: maybe test with some malleation for the deposit and spending txs? + + +def spend_confirmed_noticed(minisafed, outpoint): + c = get_coin(minisafed, outpoint) + if c["spend_info"] is None: + return False + if c["spend_info"]["height"] is None: + return False + return True + + +def test_reorg_status_recovery(minisafed, bitcoind): + """ + Test the coins that were not unconfirmed recover their initial state after a reorg. + """ + list_coins = lambda: minisafed.rpc.listcoins()["coins"] + + # Create two confirmed coins. Note how we take the initial_height after having + # mined them, as we'll reorg back to this height and due to anti fee-sniping + # these deposit transactions might not be valid anymore! + addresses = (minisafed.rpc.getnewaddress()["address"] for _ in range(2)) + txids = [bitcoind.rpc.sendtoaddress(addr, 0.5670) for addr in addresses] + bitcoind.generate_block(1, wait_for_mempool=txids) + initial_height = bitcoind.rpc.getblockcount() + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == initial_height) + + # Both coins are confirmed. Spend the second one then get their infos. + wait_for(lambda: len(list_coins()) == 2) + wait_for(lambda: all(c["block_height"] is not None for c in list_coins())) + coin_b = get_coin(minisafed, txids[1]) + spend_coins(minisafed, bitcoind, [coin_b]) + bitcoind.generate_block(1, wait_for_mempool=1) + wait_for(lambda: spend_confirmed_noticed(minisafed, coin_b["outpoint"])) + coin_a = get_coin(minisafed, txids[0]) + coin_b = get_coin(minisafed, txids[1]) + + # Reorg the chain down to the initial height without shifting nor malleating + # any transaction. The coin info should be identical (except the transaction + # spending the second coin will be mined at the height the reorg happened). + bitcoind.simple_reorg(initial_height, shift=0) + new_height = bitcoind.rpc.getblockcount() + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == new_height) + new_coin_a = get_coin(minisafed, coin_a["outpoint"]) + assert coin_a == new_coin_a + new_coin_b = get_coin(minisafed, coin_b["outpoint"]) + coin_b["spend_info"]["height"] = initial_height + assert new_coin_b == coin_b diff --git a/tests/test_framework/bitcoind.py b/tests/test_framework/bitcoind.py index 740c36f8..0bb82136 100644 --- a/tests/test_framework/bitcoind.py +++ b/tests/test_framework/bitcoind.py @@ -126,12 +126,17 @@ class Bitcoind(TailableProc): for _ in range(n): self.rpc.generateblock(addr, []) + def invalidate_remine(self, height): + delta = self.rpc.getblockcount() - height + 1 + h = self.rpc.getblockhash(height) + self.rpc.invalidateblock(h) + self.generate_empty_blocks(delta) + def simple_reorg(self, height, shift=0): """ Reorganize chain by creating a fork at height={height} and: - If shift >=0: - re-mine all mempool transactions into {height} + shift - (with shift floored at 1) - Else: - don't re-mine the mempool transactions diff --git a/tests/test_framework/utils.py b/tests/test_framework/utils.py index 9b1805f9..c48b0d58 100644 --- a/tests/test_framework/utils.py +++ b/tests/test_framework/utils.py @@ -8,6 +8,9 @@ import subprocess import threading import time +from io import BytesIO +from .serializations import CTransaction, PSBT + TIMEOUT = int(os.getenv("TIMEOUT", 20)) EXECUTOR_WORKERS = int(os.getenv("EXECUTOR_WORKERS", 20)) VERBOSE = os.getenv("VERBOSE", "0") == "1" @@ -43,6 +46,35 @@ def wait_for(success, timeout=TIMEOUT, debug_fn=None): raise ValueError("Error waiting for {}", success) +def get_txid(hex_tx): + """Get the txid (as hex) of the given (as hex) transaction.""" + tx = CTransaction() + tx.deserialize(BytesIO(bytes.fromhex(hex_tx))) + return tx.txid().hex() + + +def spend_coins(minisafed, bitcoind, coins): + """Spend these coins, no matter how. + This will create a single transaction spending them all at once at the minimum + feerate. This will broadcast but not confirm the transaction. + + :param coins: a list of dict as returned by listcoins. The coins must all exist. + :returns: the broadcasted transaction, as hex. + """ + total_value = sum(c["amount"] for c in coins) + destinations = { + bitcoind.rpc.getnewaddress(): total_value - 11 - 31 - 300 * len(coins) + } + res = minisafed.rpc.createspend([c["outpoint"] for c in coins], destinations, 1) + + psbt = PSBT() + psbt.deserialize(res["psbt"]) + tx = minisafed.sign_psbt(psbt) + bitcoind.rpc.sendrawtransaction(tx) + + return tx + + class RpcError(ValueError): def __init__(self, method: str, params: dict, error: str): super(ValueError, self).__init__( diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 89030860..44e0c06e 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -1,13 +1,13 @@ from fixtures import * from test_framework.serializations import PSBT -from test_framework.utils import wait_for, COIN +from test_framework.utils import wait_for, COIN, get_txid, spend_coins def test_getinfo(minisafed): res = minisafed.rpc.getinfo() assert res["version"] == "0.1" assert res["network"] == "regtest" - assert res["blockheight"] == 101 + wait_for(lambda: res["blockheight"] == 101) assert res["sync"] == 1.0 assert "main" in res["descriptors"] @@ -34,6 +34,7 @@ def test_listcoins(minisafed, bitcoind): assert txid == res[0]["outpoint"][:64] assert res[0]["amount"] == 1 * COIN assert res[0]["block_height"] is None + assert res[0]["spend_info"] is None # If the coin gets confirmed, it'll be marked as such. bitcoind.generate_block(1, wait_for_mempool=txid) @@ -42,6 +43,22 @@ def test_listcoins(minisafed, bitcoind): lambda: minisafed.rpc.listcoins()["coins"][0]["block_height"] == block_height ) + # Same if the coin gets spent. + spend_tx = spend_coins(minisafed, bitcoind, (res[0],)) + spend_txid = get_txid(spend_tx) + wait_for(lambda: minisafed.rpc.listcoins()["coins"][0]["spend_info"] is not None) + spend_info = minisafed.rpc.listcoins()["coins"][0]["spend_info"] + assert spend_info["txid"] == spend_txid + assert spend_info["height"] is None + + # And if this spending tx gets confirmed. + bitcoind.generate_block(1, wait_for_mempool=spend_txid) + curr_height = bitcoind.rpc.getblockcount() + wait_for(lambda: minisafed.rpc.getinfo()["blockheight"] == curr_height) + spend_info = minisafed.rpc.listcoins()["coins"][0]["spend_info"] + assert spend_info["txid"] == spend_txid + assert spend_info["height"] == curr_height + def test_jsonrpc_server(minisafed, bitcoind): """Test passing parameters as a list or a mapping."""