diff --git a/doc/API.md b/doc/API.md index 887cfc4d..501562c1 100644 --- a/doc/API.md +++ b/doc/API.md @@ -81,14 +81,20 @@ This command does not take any parameter for now. ### `listcoins` -List all our transaction outputs, regardless of their state (unspent or not). +List all our transaction outputs, optionally filtered by status and/or outpoint. #### Request -This command does not take any parameter for now. +| Field | Type | Description | +| -------------- | ----------------- | ----------------------------------------------------------------- | +| `statuses` | list of string | List of statuses to filter coins by (see below). | +| `outpoints` | list of string | List of outpoints to filter coins by, as `txid:vout`. | -| Field | Type | Description | -| ------------- | ----------------- | ----------------------------------------------------------- | +A coin may have one of the following four statuses: +- `unconfirmed`: deposit transaction has not yet been included in a block and coin has not been included in a spend transaction +- `confirmed`: deposit transaction has been included in a block and coin has not been included in a spend transaction +- `spending`: coin (whose deposit transaction may not yet have been confirmed) has been included in an unconfirmed spend transaction +- `spent`: coin has been included in a confirmed spend transaction #### Response diff --git a/src/bitcoin/poller/looper.rs b/src/bitcoin/poller/looper.rs index 3a419a6f..b15684b3 100644 --- a/src/bitcoin/poller/looper.rs +++ b/src/bitcoin/poller/looper.rs @@ -1,6 +1,6 @@ use crate::{ bitcoin::{BitcoinInterface, BlockChainTip, UTxO}, - database::{Coin, CoinType, DatabaseConnection, DatabaseInterface}, + database::{Coin, DatabaseConnection, DatabaseInterface}, descriptors, }; @@ -34,7 +34,7 @@ fn update_coins( secp: &secp256k1::Secp256k1, ) -> UpdatedCoins { let network = db_conn.network(); - let curr_coins = db_conn.coins(CoinType::All); + let curr_coins = db_conn.coins(&[], &[]); log::debug!("Current coins: {:?}", curr_coins); // Start by fetching newly received coins. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4c264a77..fb2c2b22 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,11 +6,11 @@ mod utils; use crate::{ bitcoin::BitcoinInterface, - database::{Coin, CoinType, DatabaseInterface}, + database::{Coin, DatabaseInterface}, descriptors, DaemonControl, VERSION, }; -pub use crate::database::LabelItem; +pub use crate::database::{CoinStatus, LabelItem}; use utils::{ deser_addr_assume_checked, deser_amount_from_sats, deser_fromstr, deser_hex, ser_amount, @@ -289,11 +289,15 @@ impl DaemonControl { GetAddressResult::new(address) } - /// Get a list of all known coins. - pub fn list_coins(&self) -> ListCoinsResult { + /// Get a list of all known coins, optionally by status and/or outpoint. + pub fn list_coins( + &self, + statuses: &[CoinStatus], + outpoints: &[bitcoin::OutPoint], + ) -> ListCoinsResult { let mut db_conn = self.db.connection(); let coins: Vec = db_conn - .coins(CoinType::All) + .coins(statuses, outpoints) .into_values() .map(|coin| { let Coin { @@ -747,12 +751,15 @@ impl DaemonControl { let timelock = timelock.unwrap_or_else(|| self.config.main_descriptor.first_timelock_value()); let height_delta: i32 = timelock.try_into().expect("Must fit, it's a u16"); - let sweepable_coins = db_conn.coins(CoinType::Unspent).into_values().filter(|c| { - // We are interested in coins available at the *next* block - c.block_info - .map(|b| current_height + 1 >= b.height + height_delta) - .unwrap_or(false) - }); + let sweepable_coins = db_conn + .coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[]) + .into_values() + .filter(|c| { + // We are interested in coins available at the *next* block + c.block_info + .map(|b| current_height + 1 >= b.height + height_delta) + .unwrap_or(false) + }); // Fill-in the transaction inputs and PSBT inputs information. Record the value // that is fed to the transaction while doing so, to compute the fees afterward. diff --git a/src/database/mod.rs b/src/database/mod.rs index 355d8024..a776ffbb 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -85,7 +85,11 @@ pub trait DatabaseConnection { ) -> Option<(bip32::ChildNumber, bool)>; /// Get all our coins, past or present, spent or not. - fn coins(&mut self, coin_type: CoinType) -> HashMap; + fn coins( + &mut self, + statuses: &[CoinStatus], + outpoints: &[bitcoin::OutPoint], + ) -> HashMap; /// List coins that are being spent and whose spending transaction is still unconfirmed. fn list_spending_coins(&mut self) -> HashMap; @@ -192,8 +196,12 @@ impl DatabaseConnection for SqliteConn { self.complete_wallet_rescan() } - fn coins(&mut self, coin_type: CoinType) -> HashMap { - self.coins(coin_type) + fn coins( + &mut self, + statuses: &[CoinStatus], + outpoints: &[bitcoin::OutPoint], + ) -> HashMap { + self.coins(statuses, outpoints) .into_iter() .map(|db_coin| (db_coin.outpoint, db_coin.into())) .collect() @@ -348,13 +356,31 @@ impl Coin { } } +/// Possible (mutually exclusive) status of a coin. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum CoinType { - All, - Unspent, +pub enum CoinStatus { + /// Has not yet been included in a block and has no spend transaction. + Unconfirmed, + /// Has been included in a block and has no spend transaction. + Confirmed, + /// Has an unconfirmed spend transaction, but coin itself may not yet have been included in a block. + Spending, + /// Has a confirmed spend transaction. Spent, } +impl CoinStatus { + pub fn from_arg(s: &str) -> Option { + match s { + "unconfirmed" => Some(CoinStatus::Unconfirmed), + "confirmed" => Some(CoinStatus::Confirmed), + "spending" => Some(CoinStatus::Spending), + "spent" => Some(CoinStatus::Spent), + _ => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum LabelItem { Address(bitcoin::Address), diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index 93b124bb..b18377dd 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -23,7 +23,7 @@ use crate::{ maybe_apply_migration, LOOK_AHEAD_LIMIT, }, }, - Coin, CoinType, LabelItem, + Coin, CoinStatus, LabelItem, }, descriptors::LianaDescriptor, }; @@ -357,30 +357,72 @@ impl SqliteConn { .expect("Database must be available"); } - /// Get all the coins from DB. - pub fn coins(&mut self, coin_type: CoinType) -> Vec { - db_query( - &mut self.conn, - match coin_type { - CoinType::All => "SELECT * FROM coins", - CoinType::Unspent => "SELECT * FROM coins WHERE spend_txid IS NULL", - CoinType::Spent => "SELECT * FROM coins WHERE spend_txid IS NOT NULL", - }, - rusqlite::params![], - |row| row.try_into(), - ) + /// Get all the coins from DB, optionally filtered by coin status and/or outpoint. + pub fn coins( + &mut self, + statuses: &[CoinStatus], + outpoints: &[bitcoin::OutPoint], + ) -> Vec { + let status_condition = statuses + .iter() + .map(|c| { + format!( + "({})", + match c { + CoinStatus::Unconfirmed => { + "blocktime IS NULL AND spend_txid IS NULL" + } + CoinStatus::Confirmed => { + "blocktime IS NOT NULL AND spend_txid IS NULL" + } + CoinStatus::Spending => { + "spend_txid IS NOT NULL AND spend_block_time IS NULL" + } + CoinStatus::Spent => "spend_block_time IS NOT NULL", + } + ) + }) + .collect::>() + .join(" OR "); + // SELECT * FROM coins WHERE (txid, vout) IN ((txidA, voutA), (txidB, voutB)); + let op_condition = if !outpoints.is_empty() { + let mut cond = "(txid, vout) IN (VALUES ".to_string(); + for (i, outpoint) in outpoints.iter().enumerate() { + // NOTE: SQLite doesn't know Satoshi decided txids would be displayed as little-endian + // hex. + cond += &format!( + "(x'{}', {})", + FrontwardHexTxid(outpoint.txid), + outpoint.vout + ); + if i != outpoints.len() - 1 { + cond += ", "; + } + } + cond += ")"; + cond + } else { + String::new() + }; + let where_clause = if !status_condition.is_empty() && !op_condition.is_empty() { + format!(" WHERE ({}) AND ({})", status_condition, op_condition) + } else if status_condition.is_empty() && !op_condition.is_empty() { + format!(" WHERE {}", op_condition) + } else if !status_condition.is_empty() && op_condition.is_empty() { + format!(" WHERE {}", status_condition) + } else { + String::new() + }; + let query = format!("SELECT * FROM coins{}", where_clause); + db_query(&mut self.conn, &query, rusqlite::params![], |row| { + row.try_into() + }) .expect("Db must not fail") } /// List coins that are being spent and whose spending transaction is still unconfirmed. pub fn list_spending_coins(&mut self) -> Vec { - db_query( - &mut self.conn, - "SELECT * FROM coins WHERE spend_txid IS NOT NULL AND spend_block_time IS NULL", - rusqlite::params![], - |row| row.try_into(), - ) - .expect("Db must not fail") + self.coins(&[CoinStatus::Spending], &[]) } // FIXME: don't take the whole coin, we don't need it. @@ -445,7 +487,7 @@ impl SqliteConn { .expect("Database must be available") } - /// Mark a set of coins as spent. + /// Mark a set of coins as spending. pub fn spend_coins<'a>( &mut self, outpoints: impl IntoIterator, @@ -504,26 +546,7 @@ impl SqliteConn { } pub fn db_coins(&mut self, outpoints: &[bitcoin::OutPoint]) -> Vec { - // SELECT * FROM coins WHERE (txid, vout) IN ((txidA, voutA), (txidB, voutB)); - let mut query = "SELECT * FROM coins WHERE (txid, vout) IN (VALUES ".to_string(); - for (i, outpoint) in outpoints.iter().enumerate() { - // NOTE: SQLite doesn't know Satoshi decided txids would be displayed as little-endian - // hex. - query += &format!( - "(x'{}', {})", - FrontwardHexTxid(outpoint.txid), - outpoint.vout - ); - if i != outpoints.len() - 1 { - query += ", "; - } - } - query += ")"; - - db_query(&mut self.conn, &query, rusqlite::params![], |row| { - row.try_into() - }) - .expect("Db must not fail") + self.coins(&[], outpoints) } pub fn db_spend(&mut self, txid: &bitcoin::Txid) -> Option { @@ -889,6 +912,315 @@ CREATE TABLE spend_transactions ( fs::remove_dir_all(tmp_dir).unwrap(); } + #[test] + fn db_coins() { + let (tmp_dir, _, _, db) = dummy_db(); + + { + let mut conn = db.connection().unwrap(); + + // Necessarily empty at first. + assert!(conn.coins(&[], &[]).is_empty()); + + // Add one unconfirmed coin. + let outpoint_a = bitcoin::OutPoint::from_str( + "6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1", + ) + .unwrap(); + let coin_a = Coin { + outpoint: outpoint_a, + is_immature: false, + block_info: None, + amount: bitcoin::Amount::from_sat(10000), + derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), + is_change: false, + spend_txid: None, + spend_block: None, + }; + conn.new_unspent_coins(&[coin_a]); + // We can query by status and/or outpoint. + assert!(vec![ + conn.coins(&[], &[]), + conn.coins(&[CoinStatus::Unconfirmed], &[]), + conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_a]), + conn.coins(&[], &[outpoint_a]), + conn.db_coins(&[outpoint_a]), + ] + .iter() + .all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint)); + // It will not be returned if we filter for other statuses. + assert!(conn + .coins( + &[ + CoinStatus::Confirmed, + CoinStatus::Spending, + CoinStatus::Spent + ], + &[] + ) + .is_empty()); + // Filtering also for its outpoint will still not return it if status does not match. + assert!(conn + .coins( + &[ + CoinStatus::Confirmed, + CoinStatus::Spending, + CoinStatus::Spent + ], + &[outpoint_a] + ) + .is_empty()); + + // Add a second coin. + let outpoint_b = bitcoin::OutPoint::from_str( + "61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571936f:12", + ) + .unwrap(); + let coin_b = Coin { + outpoint: outpoint_b, + is_immature: false, + block_info: None, + amount: bitcoin::Amount::from_sat(1111), + derivation_index: bip32::ChildNumber::from_normal_idx(103).unwrap(), + is_change: true, + spend_txid: None, + spend_block: None, + }; + conn.new_unspent_coins(&[coin_b]); + // Both coins are unconfirmed. + assert!(vec![ + conn.coins(&[], &[]), + conn.coins(&[CoinStatus::Unconfirmed], &[]), + conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_a, outpoint_b]), + conn.coins(&[], &[outpoint_a, outpoint_b]), + conn.db_coins(&[outpoint_a, outpoint_b]), + ] + .iter() + .all(|c| c.len() == 2 + && c[0].outpoint == coin_a.outpoint + && c[1].outpoint == coin_b.outpoint)); + // We can filter for just the first coin. + assert!(vec![ + conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_a]), + conn.coins(&[], &[outpoint_a]), + conn.db_coins(&[outpoint_a]) + ] + .iter() + .all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint)); + // Or we can filter for just the second coin. + assert!(vec![ + conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_b]), + conn.coins(&[], &[outpoint_b]), + conn.db_coins(&[outpoint_b]) + ] + .iter() + .all(|res| res.len() == 1 && res[0].outpoint == coin_b.outpoint)); + // There are no coins with other statuses. + assert!(conn + .coins( + &[ + CoinStatus::Confirmed, + CoinStatus::Spending, + CoinStatus::Spent + ], + &[] + ) + .is_empty()); + // Now if we confirm one, it'll be marked as such. + conn.confirm_coins(&[(coin_a.outpoint, 174500, 174500)]); + assert!(vec![ + conn.coins(&[CoinStatus::Confirmed], &[]), + conn.coins(&[CoinStatus::Confirmed], &[outpoint_a]), + conn.coins(&[], &[outpoint_a]), + conn.db_coins(&[outpoint_a]), + ] + .iter() + .all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint)); + // We can get both confirmed and unconfirmed. + assert!(vec![ + conn.coins(&[], &[]), + conn.coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[]), + conn.coins( + &[CoinStatus::Unconfirmed, CoinStatus::Confirmed], + &[outpoint_a, outpoint_b] + ), + conn.coins(&[], &[outpoint_a, outpoint_b]), + conn.db_coins(&[outpoint_a, outpoint_b]), + ] + .iter() + .all(|c| c.len() == 2 + && c[0].outpoint == coin_a.outpoint + && c[1].outpoint == coin_b.outpoint)); + + // Now if we spend one, it'll be marked as such. + conn.spend_coins(&[( + coin_a.outpoint, + bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(), + )]); + assert!(vec![ + conn.coins(&[CoinStatus::Spending], &[]), + conn.coins(&[CoinStatus::Spending], &[outpoint_a]), + conn.coins(&[], &[outpoint_a]), + conn.list_spending_coins(), + conn.db_coins(&[outpoint_a]) + ] + .iter() + .all(|res| res.len() == 1 && res[0].outpoint == coin_a.outpoint)); + // The second coin is still unconfirmed. + assert!(vec![ + conn.coins(&[CoinStatus::Unconfirmed], &[]), + conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_b]), + conn.coins(&[], &[outpoint_b]), + conn.db_coins(&[outpoint_b]) + ] + .iter() + .all(|res| res.len() == 1 && res[0].outpoint == coin_b.outpoint)); + + // Now we confirm the spend. + conn.confirm_spend(&[( + coin_a.outpoint, + bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(), + 128_097, + 3_000_000, + )]); + // The coin no longer has spending status. + assert!(vec![ + conn.coins(&[CoinStatus::Spending], &[]), + conn.coins(&[CoinStatus::Spending], &[outpoint_a]), + conn.list_spending_coins(), + ] + .iter() + .all(|res| res.is_empty())); + + // Both coins are still in DB. + assert!(vec![ + conn.coins(&[], &[]), + conn.coins(&[CoinStatus::Unconfirmed, CoinStatus::Spent], &[]), + conn.coins( + &[CoinStatus::Unconfirmed, CoinStatus::Spent], + &[outpoint_a, outpoint_b] + ), + conn.coins(&[], &[outpoint_a, outpoint_b]), + conn.db_coins(&[outpoint_a, outpoint_b]), + ] + .iter() + .all(|c| c.len() == 2 + && c[0].outpoint == coin_a.outpoint + && c[1].outpoint == coin_b.outpoint)); + + // Add a third and fourth coin. + let outpoint_c = bitcoin::OutPoint::from_str( + "61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571937a:42", + ) + .unwrap(); + let coin_c = Coin { + outpoint: outpoint_c, + is_immature: false, + block_info: None, + amount: bitcoin::Amount::from_sat(30000), + derivation_index: bip32::ChildNumber::from_normal_idx(4103).unwrap(), + is_change: false, + spend_txid: None, + spend_block: None, + }; + let outpoint_d = bitcoin::OutPoint::from_str( + "61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571937a:43", + ) + .unwrap(); + let coin_d = Coin { + outpoint: outpoint_d, + is_immature: false, + block_info: None, + amount: bitcoin::Amount::from_sat(40000), + derivation_index: bip32::ChildNumber::from_normal_idx(4104).unwrap(), + is_change: false, + spend_txid: None, + spend_block: None, + }; + conn.new_unspent_coins(&[coin_c, coin_d]); + + // We can get all three unconfirmed coins with different status/outpoint filters. + assert!(vec![ + conn.coins(&[CoinStatus::Unconfirmed], &[]), + conn.coins( + &[CoinStatus::Unconfirmed], + &[outpoint_b, outpoint_c, outpoint_d] + ), + conn.coins(&[], &[outpoint_b, outpoint_c, outpoint_d]), + conn.db_coins(&[outpoint_b, outpoint_c, outpoint_d]), + ] + .iter() + .all(|coin| coin.len() == 3 + && coin[0].outpoint == coin_b.outpoint + && coin[1].outpoint == coin_c.outpoint + && coin[2].outpoint == coin_d.outpoint)); + + // We can also get two of the three unconfirmed coins by filtering for their outpoints. + assert!(vec![ + conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_b, outpoint_c]), + conn.coins(&[], &[outpoint_b, outpoint_c]), + conn.db_coins(&[outpoint_b, outpoint_c]), + ] + .iter() + .all(|coin| coin.len() == 2 + && coin[0].outpoint == coin_b.outpoint + && coin[1].outpoint == coin_c.outpoint)); + + // Now spend second coin, even though it is still unconfirmed. + conn.spend_coins(&[( + coin_b.outpoint, + bitcoin::Txid::from_slice(&[1; 32][..]).unwrap(), + )]); + // The coin shows as spending. + assert!(vec![ + conn.coins(&[CoinStatus::Spending], &[]), + conn.coins(&[CoinStatus::Spending], &[outpoint_b]), + conn.coins(&[], &[outpoint_b]), + conn.list_spending_coins(), + conn.db_coins(&[outpoint_b]) + ] + .iter() + .all(|res| res.len() == 1 && res[0].outpoint == coin_b.outpoint)); + + // Now confirm the third coin. + conn.confirm_coins(&[(coin_c.outpoint, 175500, 175500)]); + + // We now only have one unconfirmed coin. + assert!(vec![ + conn.coins(&[CoinStatus::Unconfirmed], &[]), + conn.coins( + &[CoinStatus::Unconfirmed], + &[outpoint_a, outpoint_b, outpoint_c, outpoint_d] + ), + conn.coins(&[], &[outpoint_d]), + conn.db_coins(&[outpoint_d]), + ] + .iter() + .all(|c| c.len() == 1 && c[0].outpoint == coin_d.outpoint)); + + // There is now one coin for each status. + assert!(vec![ + conn.coins(&[CoinStatus::Unconfirmed], &[]), + conn.coins(&[CoinStatus::Unconfirmed], &[outpoint_d]), + conn.coins(&[CoinStatus::Confirmed], &[]), + conn.coins(&[CoinStatus::Confirmed], &[outpoint_c]), + conn.coins(&[CoinStatus::Spending], &[]), + conn.coins(&[CoinStatus::Spending], &[outpoint_b]), + conn.coins(&[CoinStatus::Spent], &[]), + conn.coins(&[CoinStatus::Spent], &[outpoint_a]), + conn.coins(&[], &[outpoint_a]), + conn.coins(&[], &[outpoint_b]), + conn.coins(&[], &[outpoint_c]), + conn.coins(&[], &[outpoint_d]), + ] + .iter() + .map(|c| c.len()) + .all(|length| length == 1)); + } + + fs::remove_dir_all(tmp_dir).unwrap(); + } + #[test] fn db_coins_update() { let (tmp_dir, _, _, db) = dummy_db(); @@ -897,7 +1229,7 @@ CREATE TABLE spend_transactions ( let mut conn = db.connection().unwrap(); // Necessarily empty at first. - assert!(conn.coins(CoinType::All).is_empty()); + assert!(conn.coins(&[], &[]).is_empty()); // Add one, we'll get it. let coin_a = Coin { @@ -914,11 +1246,11 @@ CREATE TABLE spend_transactions ( spend_block: None, }; conn.new_unspent_coins(&[coin_a]); - assert_eq!(conn.coins(CoinType::All)[0].outpoint, coin_a.outpoint); + assert_eq!(conn.coins(&[], &[])[0].outpoint, coin_a.outpoint); // We can also remove it. Say the unconfirmed tx that created it got replaced. conn.remove_coins(&[coin_a.outpoint]); - assert!(conn.coins(CoinType::All).is_empty()); + assert!(conn.coins(&[], &[]).is_empty()); // Add it back for the rest of the test. conn.new_unspent_coins(&[coin_a]); @@ -928,9 +1260,21 @@ CREATE TABLE spend_transactions ( assert_eq!(coins.len(), 1); assert_eq!(coins[0].outpoint, coin_a.outpoint); - // It is unspent. - assert_eq!(conn.coins(CoinType::Unspent)[0].outpoint, coin_a.outpoint); - assert!(conn.coins(CoinType::Spent).is_empty()); + // It is unconfirmed. + assert_eq!( + conn.coins(&[CoinStatus::Unconfirmed], &[])[0].outpoint, + coin_a.outpoint + ); + assert!(conn + .coins( + &[ + CoinStatus::Confirmed, + CoinStatus::Spending, + CoinStatus::Spent + ], + &[] + ) + .is_empty()); // Add a second one (this one is change), we'll get both. let coin_b = Coin { @@ -948,7 +1292,7 @@ CREATE TABLE spend_transactions ( }; conn.new_unspent_coins(&[coin_b]); let outpoints: HashSet = conn - .coins(CoinType::All) + .coins(&[], &[]) .into_iter() .map(|c| c.outpoint) .collect(); @@ -967,15 +1311,24 @@ CREATE TABLE spend_transactions ( assert!(coins.iter().any(|c| c.outpoint == coin_a.outpoint)); assert!(coins.iter().any(|c| c.outpoint == coin_b.outpoint)); - // They are both unspent - assert_eq!(conn.coins(CoinType::Unspent).len(), 2); - assert!(conn.coins(CoinType::Spent).is_empty()); + // They are both unconfirmed. + assert_eq!(conn.coins(&[CoinStatus::Unconfirmed], &[]).len(), 2); + assert!(conn + .coins( + &[ + CoinStatus::Confirmed, + CoinStatus::Spending, + CoinStatus::Spent + ], + &[] + ) + .is_empty()); // Now if we confirm one, it'll be marked as such. let height = 174500; let time = 174500; conn.confirm_coins(&[(coin_a.outpoint, height, time)]); - let coins = conn.coins(CoinType::All); + let coins = conn.coins(&[], &[]); assert_eq!(coins[0].block_info, Some(DbBlockInfo { height, time })); assert!(coins[1].block_info.is_none()); @@ -985,7 +1338,7 @@ CREATE TABLE spend_transactions ( bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(), )]); let coins_map: HashMap = conn - .coins(CoinType::All) + .coins(&[], &[]) .into_iter() .map(|c| (c.outpoint, c)) .collect(); @@ -1003,9 +1356,15 @@ CREATE TABLE spend_transactions ( .collect(); assert!(outpoints.contains(&coin_a.outpoint)); - // The first one is spent, not the second one. - assert_eq!(conn.coins(CoinType::Spent)[0].outpoint, coin_a.outpoint); - assert_eq!(conn.coins(CoinType::Unspent)[0].outpoint, coin_b.outpoint); + // The first one is spending, not the second one. + assert_eq!( + conn.coins(&[CoinStatus::Spending], &[])[0].outpoint, + coin_a.outpoint + ); + assert_eq!( + conn.coins(&[CoinStatus::Unconfirmed], &[])[0].outpoint, + coin_b.outpoint + ); // Now if we confirm the spend. let height = 128_097; @@ -1051,7 +1410,7 @@ CREATE TABLE spend_transactions ( }; conn.new_unspent_coins(&[coin_imma]); let outpoints: HashSet = conn - .coins(CoinType::All) + .coins(&[], &[]) .into_iter() .map(|c| c.outpoint) .collect(); @@ -1709,7 +2068,7 @@ CREATE TABLE spend_transactions ( spend_txid: None, spend_block: None, }]); - let coins = conn.coins(CoinType::All); + let coins = conn.coins(&[], &[]); assert_eq!(coins.len(), 3); assert_eq!(coins.iter().filter(|c| !c.is_immature).count(), 2); } diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index fc920bbf..3c2424d5 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -1,5 +1,5 @@ use crate::{ - commands::LabelItem, + commands::{CoinStatus, LabelItem}, jsonrpc::{Error, Params, Request, Response}, DaemonControl, }; @@ -87,6 +87,55 @@ fn broadcast_spend(control: &DaemonControl, params: Params) -> Result) -> Result { + let statuses_arg = params + .as_ref() + .and_then(|p| p.get(0, "statuses")) + .and_then(|statuses| statuses.as_array()); + let statuses: Vec = if let Some(statuses_arg) = statuses_arg { + statuses_arg + .iter() + .map(|status_arg| { + status_arg + .as_str() + .and_then(CoinStatus::from_arg) + .ok_or_else(|| { + Error::invalid_params(format!( + "Invalid value {} in 'statuses' parameter.", + status_arg + )) + }) + }) + .collect::, Error>>()? + } else { + Vec::new() + }; + let outpoints_arg = params + .as_ref() + .and_then(|p| p.get(1, "outpoints")) + .and_then(|op| op.as_array()); + let outpoints: Vec = if let Some(outpoints_arg) = outpoints_arg { + outpoints_arg + .iter() + .map(|op_arg| { + op_arg + .as_str() + .and_then(|op| bitcoin::OutPoint::from_str(op).map_or_else(|_| None, Some)) + .ok_or_else(|| { + Error::invalid_params(format!( + "Invalid value {} in 'outpoints' parameter.", + op_arg + )) + }) + }) + .collect::, Error>>()? + } else { + Vec::new() + }; + let res = control.list_coins(&statuses, &outpoints); + Ok(serde_json::json!(&res)) +} + fn list_confirmed(control: &DaemonControl, params: Params) -> Result { let start: u32 = params .get(0, "start") @@ -258,7 +307,10 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result serde_json::json!(&control.get_info()), "getnewaddress" => serde_json::json!(&control.get_new_address()), - "listcoins" => serde_json::json!(&control.list_coins()), + "listcoins" => { + let params = req.params; + list_coins(control, params)? + } "listconfirmed" => { let params = req.params.ok_or_else(|| { Error::invalid_params( diff --git a/src/testutils.rs b/src/testutils.rs index 6bb854c4..3c7e3ea6 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -1,7 +1,7 @@ use crate::{ bitcoin::{BitcoinInterface, Block, BlockChainTip, UTxO}, config::{BitcoinConfig, Config}, - database::{BlockInfo, Coin, CoinType, DatabaseConnection, DatabaseInterface, LabelItem}, + database::{BlockInfo, Coin, CoinStatus, DatabaseConnection, DatabaseInterface, LabelItem}, descriptors, DaemonHandle, }; @@ -195,19 +195,43 @@ impl DatabaseConnection for DummyDatabase { self.db.write().unwrap().change_index = index; } - fn coins(&mut self, coin_type: CoinType) -> HashMap { - let coins = self.db.read().unwrap().coins.clone(); - match coin_type { - CoinType::All => coins, - CoinType::Unspent => coins - .into_iter() - .filter(|(_, c)| c.spend_txid.is_none()) - .collect(), - CoinType::Spent => coins - .into_iter() - .filter(|(_, c)| c.spend_txid.is_some()) - .collect(), - } + fn coins( + &mut self, + statuses: &[CoinStatus], + outpoints: &[bitcoin::OutPoint], + ) -> HashMap { + self.db + .read() + .unwrap() + .coins + .clone() + .into_iter() + .filter_map(|(op, c)| { + if (c.block_info.is_none() + && c.spend_txid.is_none() + && statuses.contains(&CoinStatus::Unconfirmed)) + || (c.block_info.is_some() + && c.spend_txid.is_none() + && statuses.contains(&CoinStatus::Confirmed)) + || (c.spend_txid.is_some() + && c.spend_block.is_none() + && statuses.contains(&CoinStatus::Spending)) + || (c.spend_block.is_some() && statuses.contains(&CoinStatus::Spent)) + || statuses.is_empty() + { + Some((op, c)) + } else { + None + } + }) + .filter_map(|(op, c)| { + if outpoints.contains(&op) || outpoints.is_empty() { + Some((op, c)) + } else { + None + } + }) + .collect() } fn list_spending_coins(&mut self) -> HashMap { diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 1d031743..3a2dcc2e 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -1,5 +1,6 @@ import pytest import random +import re import time from fixtures import * @@ -44,20 +45,46 @@ def test_listcoins(lianad, bitcoind): # If we send a coin, we'll get a new entry. Note we monitor for unconfirmed # funds as well. - addr = lianad.rpc.getnewaddress()["address"] - txid = bitcoind.rpc.sendtoaddress(addr, 1) + addr_a = lianad.rpc.getnewaddress()["address"] + txid_a = bitcoind.rpc.sendtoaddress(addr_a, 1) wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 1) res = lianad.rpc.listcoins()["coins"] - assert txid == res[0]["outpoint"][:64] + outpoint_a = res[0]["outpoint"] + assert txid_a == outpoint_a[:64] assert res[0]["amount"] == 1 * COIN assert res[0]["block_height"] is None assert res[0]["spend_info"] is None + assert len(lianad.rpc.listcoins(["confirmed", "spent", "spending"])["coins"]) == 0 + assert ( + lianad.rpc.listcoins() + == lianad.rpc.listcoins([], [outpoint_a]) + == lianad.rpc.listcoins(["unconfirmed"]) + == lianad.rpc.listcoins(["unconfirmed"], [outpoint_a]) + == lianad.rpc.listcoins(["unconfirmed", "confirmed"]) + == lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"]) + == lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"], [outpoint_a]) + ) # If the coin gets confirmed, it'll be marked as such. - bitcoind.generate_block(1, wait_for_mempool=txid) + bitcoind.generate_block(1, wait_for_mempool=txid_a) block_height = bitcoind.rpc.getblockcount() wait_for(lambda: lianad.rpc.listcoins()["coins"][0]["block_height"] == block_height) + assert ( + len(lianad.rpc.listcoins()) + == len(lianad.rpc.listcoins(["confirmed"])["coins"]) + == 1 + ) + assert ( + lianad.rpc.listcoins() + == lianad.rpc.listcoins([], [outpoint_a]) + == lianad.rpc.listcoins(["confirmed"]) + == lianad.rpc.listcoins(["confirmed"], [outpoint_a]) + == lianad.rpc.listcoins(["unconfirmed", "confirmed"]) + == lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"]) + == lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"], [outpoint_a]) + ) + # Same if the coin gets spent. spend_tx = spend_coins(lianad, bitcoind, (res[0],)) spend_txid = get_txid(spend_tx) @@ -65,6 +92,8 @@ def test_listcoins(lianad, bitcoind): spend_info = lianad.rpc.listcoins()["coins"][0]["spend_info"] assert spend_info["txid"] == spend_txid assert spend_info["height"] is None + assert len(lianad.rpc.listcoins(["spent"])["coins"]) == 0 + assert len(lianad.rpc.listcoins(["spending"])["coins"]) == 1 # And if this spending tx gets confirmed. bitcoind.generate_block(1, wait_for_mempool=spend_txid) @@ -73,6 +102,220 @@ def test_listcoins(lianad, bitcoind): spend_info = lianad.rpc.listcoins()["coins"][0]["spend_info"] assert spend_info["txid"] == spend_txid assert spend_info["height"] == curr_height + assert len(lianad.rpc.listcoins(["unconfirmed", "confirmed"])["coins"]) == 0 + assert ( + lianad.rpc.listcoins() + == lianad.rpc.listcoins(["spent"]) + == lianad.rpc.listcoins(["spent", "unconfirmed", "confirmed"]) + ) + + # Add a second coin. + addr_b = lianad.rpc.getnewaddress()["address"] + txid_b = bitcoind.rpc.sendtoaddress(addr_b, 2) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 2) + res = lianad.rpc.listcoins(["unconfirmed"], [])["coins"] + outpoint_b = res[0]["outpoint"] + + # We have one unconfirmed coin and one spent coin. + assert ( + len(lianad.rpc.listcoins()["coins"]) + == len(lianad.rpc.listcoins([], [outpoint_a, outpoint_b])["coins"]) + == len(lianad.rpc.listcoins(["unconfirmed", "spent"])["coins"]) + == len( + lianad.rpc.listcoins(["unconfirmed", "spent"], [outpoint_a, outpoint_b])[ + "coins" + ] + ) + == 2 + ) + assert ( + lianad.rpc.listcoins([], [outpoint_b]) + == lianad.rpc.listcoins(["unconfirmed"]) + == lianad.rpc.listcoins(["unconfirmed"], [outpoint_b]) + == lianad.rpc.listcoins(["unconfirmed", "confirmed"]) + == lianad.rpc.listcoins(["spending", "unconfirmed", "confirmed"]) + == lianad.rpc.listcoins(["spending", "unconfirmed", "confirmed"], [outpoint_b]) + ) + + # Now confirm the second coin. + bitcoind.generate_block(1, wait_for_mempool=txid_b) + block_height = bitcoind.rpc.getblockcount() + wait_for( + lambda: lianad.rpc.listcoins([], [outpoint_b])["coins"][0]["block_height"] + == block_height + ) + + # We have one confirmed coin and one spent coin. + assert ( + len(lianad.rpc.listcoins()["coins"]) + == len(lianad.rpc.listcoins([], [outpoint_a, outpoint_b])["coins"]) + == len(lianad.rpc.listcoins(["confirmed", "spent"])["coins"]) + == len( + lianad.rpc.listcoins(["confirmed", "spent"], [outpoint_a, outpoint_b])[ + "coins" + ] + ) + == 2 + ) + assert ( + lianad.rpc.listcoins([], [outpoint_b]) + == lianad.rpc.listcoins(["confirmed"]) + == lianad.rpc.listcoins(["confirmed"], [outpoint_b]) + == lianad.rpc.listcoins(["unconfirmed", "confirmed"]) + == lianad.rpc.listcoins(["unconfirmed", "confirmed", "spending"]) + == lianad.rpc.listcoins(["unconfirmed", "confirmed", "spending"], [outpoint_b]) + ) + + # Add a third coin. + addr_c = lianad.rpc.getnewaddress()["address"] + txid_c = bitcoind.rpc.sendtoaddress(addr_c, 3) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 3) + res = lianad.rpc.listcoins(["unconfirmed"], [])["coins"] + outpoint_c = res[0]["outpoint"] + + # We have three different statuses: unconfirmed, confirmed and spent. + assert ( + len(lianad.rpc.listcoins()["coins"]) + == len(lianad.rpc.listcoins([], [outpoint_a, outpoint_b, outpoint_c])["coins"]) + == len(lianad.rpc.listcoins(["unconfirmed", "confirmed", "spent"])["coins"]) + == len( + lianad.rpc.listcoins( + ["unconfirmed", "confirmed", "spent"], + [outpoint_a, outpoint_b, outpoint_c], + )["coins"] + ) + == 3 + ) + assert ( + lianad.rpc.listcoins([], [outpoint_c]) + == lianad.rpc.listcoins(["unconfirmed"]) + == lianad.rpc.listcoins(["unconfirmed"], [outpoint_c]) + == lianad.rpc.listcoins(["unconfirmed", "spending"]) + == lianad.rpc.listcoins(["spending", "unconfirmed"]) + == lianad.rpc.listcoins(["spending", "unconfirmed", "confirmed"], [outpoint_c]) + ) + + # Spend third coin, even though it is still unconfirmed. + spend_tx = spend_coins(lianad, bitcoind, (res[0],)) + spend_txid = get_txid(spend_tx) + wait_for( + lambda: lianad.rpc.listcoins([], [outpoint_c])["coins"][0]["spend_info"] + is not None + ) + + assert len(lianad.rpc.listcoins(["unconfirmed"])["coins"]) == 0 + assert ( + len(lianad.rpc.listcoins()["coins"]) + == len(lianad.rpc.listcoins([], [outpoint_a, outpoint_b, outpoint_c])["coins"]) + == len(lianad.rpc.listcoins(["confirmed", "spending", "spent"])["coins"]) + == len( + lianad.rpc.listcoins( + ["confirmed", "spending", "spent"], [outpoint_a, outpoint_b, outpoint_c] + )["coins"] + ) + == 3 + ) + # The unconfirmed coin now has spending status. + assert ( + lianad.rpc.listcoins([], [outpoint_c]) + == lianad.rpc.listcoins(["spending"]) + == lianad.rpc.listcoins(["spending"], [outpoint_c]) + == lianad.rpc.listcoins(["spending", "unconfirmed"]) + == lianad.rpc.listcoins(["spending", "unconfirmed"], [outpoint_c]) + ) + + # Add a fourth coin. + addr_d = lianad.rpc.getnewaddress()["address"] + txid_d = bitcoind.rpc.sendtoaddress(addr_d, 4) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 4) + res = lianad.rpc.listcoins(["unconfirmed"], [])["coins"] + outpoint_d = res[0]["outpoint"] + + # We now have all four statuses. + assert ( + len(lianad.rpc.listcoins(["unconfirmed"])["coins"]) + == len(lianad.rpc.listcoins(["confirmed"])["coins"]) + == len(lianad.rpc.listcoins(["spending"])["coins"]) + == len(lianad.rpc.listcoins(["spent"])["coins"]) + == 1 + ) + assert ( + len(lianad.rpc.listcoins()["coins"]) + == len( + lianad.rpc.listcoins([], [outpoint_a, outpoint_b, outpoint_c, outpoint_d])[ + "coins" + ] + ) + == len( + lianad.rpc.listcoins(["unconfirmed", "confirmed", "spending", "spent"])[ + "coins" + ] + ) + == len( + lianad.rpc.listcoins( + ["unconfirmed", "confirmed", "spending", "spent"], + [outpoint_a, outpoint_b, outpoint_c, outpoint_d], + )["coins"] + ) + == 4 + ) + + # We can filter for specific statuses/outpoints. + assert ( + sorted( + lianad.rpc.listcoins(["spending", "spent"])["coins"], + key=lambda c: c["outpoint"], + ) + == sorted( + lianad.rpc.listcoins(["spending", "spent"], [outpoint_a, outpoint_c])[ + "coins" + ], + key=lambda c: c["outpoint"], + ) + == sorted( + lianad.rpc.listcoins( + ["unconfirmed", "confirmed", "spending", "spent"], + [outpoint_a, outpoint_c], + )["coins"], + key=lambda c: c["outpoint"], + ) + == sorted( + lianad.rpc.listcoins( + ["spending", "spent"], [outpoint_a, outpoint_b, outpoint_c, outpoint_d] + )["coins"], + key=lambda c: c["outpoint"], + ) + ) + + # Finally, check that we return errors for invalid parameter values. + for statuses, outpoints in [ + (["fake_status"], []), + (["spent", "fake_status"], []), + (["fake_status", "fake_status_2"], []), + (["confirmed", "spending", "fake_status"], ["fake_outpoint"]), + (["fake_status"], [outpoint_a, outpoint_b]), + ]: + with pytest.raises( + RpcError, + match=re.escape( + "Invalid params: Invalid value \"fake_status\" in \\'statuses\\' parameter." + ), + ): + lianad.rpc.listcoins(statuses, outpoints) + + for statuses, outpoints in [ + ([], ["fake_outpoint"]), + ([], [outpoint_a, "fake_outpoint", outpoint_b]), + ([], [outpoint_a, "fake_outpoint", "fake_outpoint_2"]), + ([], [outpoint_a, outpoint_b, "fake_outpoint"]), + ]: + with pytest.raises( + RpcError, + match=re.escape( + "Invalid params: Invalid value \"fake_outpoint\" in \\'outpoints\\' parameter." + ), + ): + lianad.rpc.listcoins(statuses, outpoints) def test_jsonrpc_server(lianad, bitcoind):