From 3cd709697b4c0f098b9938dc122ddf471659dbe9 Mon Sep 17 00:00:00 2001 From: edouard Date: Thu, 25 Aug 2022 17:57:18 +0200 Subject: [PATCH 1/4] Add listconfirmed command --- doc/API.md | 50 ++++-- src/bitcoin/d/mod.rs | 35 ++++- src/bitcoin/mod.rs | 60 +++++--- src/bitcoin/poller/looper.rs | 6 +- src/commands/mod.rs | 290 ++++++++++++++++++++++++++++++++++- src/commands/utils.rs | 28 +++- src/database/mod.rs | 7 + src/database/sqlite/mod.rs | 185 ++++++++++++++++++++++ src/jsonrpc/api.rs | 37 +++++ src/jsonrpc/server.rs | 2 +- src/testutils.rs | 120 +++++++++++---- 11 files changed, 732 insertions(+), 88 deletions(-) diff --git a/doc/API.md b/doc/API.md index 32d9454f..db4187d9 100644 --- a/doc/API.md +++ b/doc/API.md @@ -5,16 +5,17 @@ interface over a Unix Domain socket. Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`. -| Command | Description | -| ----------------------------------------------------------- | ---------------------------------------------------- | -| [`stop`](#stop) | Stops the minisafe daemon | -| [`getinfo`](#getinfo) | Get general information about the daemon | -| [`getnewaddress`](#getnewaddress) | Get a new receiving address | -| [`listcoins`](#listcoins) | List all wallet transaction outputs. | -| [`listspendtxs`](#listspendtxs) | List all stored Spend transactions | -| [`delspendtx`](#delspendtx) | Delete a stored Spend transaction | -| [`broadcastspend`](#broadcastspend) | Finalize a stored Spend PSBT, and broadcast it | -| [`startrescan`](#startrescan) | Start rescanning the block chain from a given date | +| Command | Description | +| ----------------------------------------------------------- | ---------------------------------------------------- | +| [`stop`](#stop) | Stops the minisafe daemon | +| [`getinfo`](#getinfo) | Get general information about the daemon | +| [`getnewaddress`](#getnewaddress) | Get a new receiving address | +| [`listcoins`](#listcoins) | List all wallet transaction outputs. | +| [`listspendtxs`](#listspendtxs) | List all stored Spend transactions | +| [`delspendtx`](#delspendtx) | Delete a stored Spend transaction | +| [`broadcastspend`](#broadcastspend) | Finalize a stored Spend PSBT, and broadcast it | +| [`startrescan`](#startrescan) | Start rescanning the block chain from a given date | +| [`listconfirmed`](#listconfirmed) | List of confirmed transactions of incoming and outgoing funds | # Reference @@ -187,7 +188,6 @@ This command does not return anything for now. | Field | Type | Description | | -------------- | --------- | ---------------------------------------------------- | - ### `broadcastspend` #### Request @@ -203,7 +203,6 @@ This command does not return anything for now. | Field | Type | Description | | -------------- | --------- | ---------------------------------------------------- | - ### `startrescan` #### Request @@ -218,3 +217,30 @@ This command does not return anything for now. | Field | Type | Description | | -------------- | --------- | ---------------------------------------------------- | + +### `listconfirmed` + +`listconfirmed` retrieves a paginated and ordered list of transactions that were confirmed within a given time window. +Confirmation time is based on the timestamp of blocks. + +#### Request + +| Field | Type | Description | +| ------------- | ------------ | ------------------------------------------ | +| `start` | int | Inclusive lower bound of the time window | +| `end` | int | Inclusive upper bound of the time window | +| `limit` | int | Maximum number of transactions to retrieve | + +#### Response + +| Field | Type | Description | +| -------------- | ------ | ------------------------------------------------------ | +| `transactions` | array | Array of [Transaction resource](#transaction-resource) | + +##### Transaction Resource + +| Field | Type | Description | +| -------- | ------------- | ------------------------------------------------------------------------- | +| `height` | int or `null` | Block height of the transaction, `null` if the transaction is unconfirmed | +| `time` | int or `null` | Block time of the transaction, `null` if the transaction is unconfirmed | +| `tx` | string | hex encoded bitcoin transaction | diff --git a/src/bitcoin/d/mod.rs b/src/bitcoin/d/mod.rs index 65229e8b..782dff46 100644 --- a/src/bitcoin/d/mod.rs +++ b/src/bitcoin/d/mod.rs @@ -2,7 +2,11 @@ ///! ///! We use the RPC interface and a watchonly descriptor wallet. mod utils; -use crate::{bitcoin::BlockChainTip, config, descriptors::MultipathDescriptor}; +use crate::{ + bitcoin::{Block, BlockChainTip}, + config, + descriptors::MultipathDescriptor, +}; use utils::block_before_date; use std::{cmp, collections::HashSet, convert::TryInto, fs, io, str::FromStr, time::Duration}; @@ -12,7 +16,11 @@ use jsonrpc::{ client::Client, simple_http::{self, SimpleHttpTransport}, }; -use miniscript::{bitcoin, descriptor}; + +use miniscript::{ + bitcoin::{self, hashes::hex::FromHex}, + descriptor, +}; use serde_json::Value as Json; @@ -1035,12 +1043,15 @@ impl From for LSBlockRes { #[derive(Debug, Clone)] pub struct GetTxRes { pub conflicting_txs: Vec, - pub block_height: Option, - pub block_time: Option, + pub block: Option, + pub tx: bitcoin::Transaction, } impl From for GetTxRes { fn from(json: Json) -> GetTxRes { + let block_hash = json.get("blockhash").and_then(Json::as_str).map(|s| { + bitcoin::BlockHash::from_str(s).expect("Invalid blockhash in `gettransaction` response") + }); let block_height = json .get("blockheight") .and_then(Json::as_i64) @@ -1060,11 +1071,21 @@ impl From for GetTxRes { }) .collect() }); - + let block = match (block_hash, block_height, block_time) { + (Some(hash), Some(height), Some(time)) => Some(Block { hash, time, height }), + _ => None, + }; + let hex = json + .get("hex") + .and_then(Json::as_str) + .expect("Must be present in bitcoind response"); + let bytes = Vec::from_hex(hex).expect("bitcoind returned a wrong transaction format"); + let tx: bitcoin::Transaction = bitcoin::consensus::encode::deserialize(&bytes) + .expect("bitcoind returned a wrong transaction format"); GetTxRes { conflicting_txs: conflicting_txs.unwrap_or_default(), - block_height, - block_time, + block, + tx, } } } diff --git a/src/bitcoin/mod.rs b/src/bitcoin/mod.rs index ae771dcc..2646dbee 100644 --- a/src/bitcoin/mod.rs +++ b/src/bitcoin/mod.rs @@ -13,6 +13,14 @@ use std::{collections::HashMap, fmt, sync}; use miniscript::bitcoin; +/// Information about a block +#[derive(Debug, Clone, Eq, PartialEq, Copy)] +pub struct Block { + pub hash: bitcoin::BlockHash, + pub height: i32, + pub time: u32, +} + /// Information about the best block in the chain #[derive(Debug, Clone, Eq, PartialEq, Copy)] pub struct BlockChainTip { @@ -66,7 +74,7 @@ pub trait BitcoinInterface: Send { fn spent_coins( &self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)], - ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)>; + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, Block)>; /// Get the common ancestor between the Bitcoin backend's tip and the given tip. fn common_ancestor(&self, tip: &BlockChainTip) -> Option; @@ -88,6 +96,12 @@ pub trait BitcoinInterface: Send { /// Get the last block chain tip with a timestamp below this. Timestamp must be a valid block /// timestamp. fn block_before_date(&self, timestamp: u32) -> Option; + + /// Get a transaction related to the wallet along with potential confirmation info. + fn wallet_transaction( + &self, + txid: &bitcoin::Txid, + ) -> Option<(bitcoin::Transaction, Option)>; } impl BitcoinInterface for d::BitcoinD { @@ -158,10 +172,8 @@ impl BitcoinInterface for d::BitcoinD { for op in outpoints { // TODO: batch those calls to gettransaction if let Some(res) = self.get_transaction(&op.txid) { - if let Some(h) = res.block_height { - if let Some(t) = res.block_time { - confirmed.push((*op, h, t)); - } + if let Some(block) = res.block { + confirmed.push((*op, block.height, block.time)); } } else { log::error!("Transaction not in wallet for coin '{}'.", op); @@ -200,7 +212,7 @@ impl BitcoinInterface for d::BitcoinD { fn spent_coins( &self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)], - ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> { + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, Block)> { let mut spent = Vec::with_capacity(outpoints.len()); let mut cache: HashMap> = HashMap::new(); @@ -219,15 +231,8 @@ 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_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_height, - tx.block_time.expect("Confirmed tx."), - )); + if let Some(block) = tx.block { + spent.push((*op, *txid, block)); } else if !tx.conflicting_txs.is_empty() { for txid in &tx.conflicting_txs { let tx: Option<&d::GetTxRes> = match cache.get(txid) { @@ -239,13 +244,8 @@ impl BitcoinInterface for d::BitcoinD { } }; if let Some(tx) = tx { - if let Some(block_height) = tx.block_height { - spent.push(( - *op, - *txid, - block_height, - tx.block_time.expect("Spend is confirmed"), - )) + if let Some(block) = tx.block { + spent.push((*op, *txid, block)) } } } @@ -309,6 +309,13 @@ impl BitcoinInterface for d::BitcoinD { let tip = self.chain_tip(); self.get_block_stats(tip.hash).time } + + fn wallet_transaction( + &self, + txid: &bitcoin::Txid, + ) -> Option<(bitcoin::Transaction, Option)> { + self.get_transaction(txid).map(|res| (res.tx, res.block)) + } } // FIXME: do we need to repeat the entire trait implemenation? Isn't there a nicer way? @@ -354,7 +361,7 @@ impl BitcoinInterface for sync::Arc> fn spent_coins( &self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)], - ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> { + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, Block)> { self.lock().unwrap().spent_coins(outpoints) } @@ -385,6 +392,13 @@ impl BitcoinInterface for sync::Arc> fn tip_time(&self) -> u32 { self.lock().unwrap().tip_time() } + + fn wallet_transaction( + &self, + txid: &bitcoin::Txid, + ) -> Option<(bitcoin::Transaction, Option)> { + self.lock().unwrap().wallet_transaction(txid) + } } // 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 77ff6b27..aa9c8648 100644 --- a/src/bitcoin/poller/looper.rs +++ b/src/bitcoin/poller/looper.rs @@ -121,7 +121,11 @@ fn update_coins( .map(|coin| (coin.outpoint, coin.spend_txid.expect("Coin is spending"))) .chain(spending.iter().cloned()) .collect(); - let spent = bit.spent_coins(spending_coins.as_slice()); + let spent = bit + .spent_coins(spending_coins.as_slice()) + .into_iter() + .map(|(oupoint, txid, block)| (oupoint, txid, block.height, block.time)) + .collect(); log::debug!("Newly spent coins: {:?}", spent); UpdatedCoins { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 755b9e5f..feed033e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,7 +9,10 @@ use crate::{ database::{Coin, DatabaseInterface}, descriptors, DaemonControl, VERSION, }; -use utils::{change_index, deser_amount_from_sats, deser_psbt_base64, ser_amount, ser_base64}; + +use utils::{ + change_index, deser_amount_from_sats, deser_base64, deser_hex, ser_amount, ser_base64, ser_hex, +}; use std::{ collections::{BTreeMap, HashMap}, @@ -555,6 +558,32 @@ impl DaemonControl { Ok(()) } + + /// list_confirmed_transactions retrieves a limited list of transactions which occured between two given dates. + pub fn list_confirmed_transactions( + &self, + start: u32, + end: u32, + limit: u64, + ) -> ListTransactionsResult { + let mut db_conn = self.db.connection(); + let txids = db_conn.list_txids(start, end, limit); + let transactions = txids + .iter() + .filter_map(|txid| { + // TODO: batch batch those calls to the Bitcoin backend + // so it can in turn optimize its queries. + self.bitcoin + .wallet_transaction(txid) + .map(|(tx, block)| TransactionInfo { + tx, + height: block.map(|b| b.height), + time: block.map(|b| b.time), + }) + }) + .collect(); + ListTransactionsResult { transactions } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -606,13 +635,13 @@ pub struct ListCoinsResult { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CreateSpendResult { - #[serde(serialize_with = "ser_base64", deserialize_with = "deser_psbt_base64")] + #[serde(serialize_with = "ser_base64", deserialize_with = "deser_base64")] pub psbt: Psbt, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListSpendEntry { - #[serde(serialize_with = "ser_base64", deserialize_with = "deser_psbt_base64")] + #[serde(serialize_with = "ser_base64", deserialize_with = "deser_base64")] pub psbt: Psbt, pub change_index: Option, } @@ -622,17 +651,35 @@ pub struct ListSpendResult { pub spend_txs: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListTransactionsResult { + pub transactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionInfo { + #[serde(serialize_with = "ser_hex", deserialize_with = "deser_hex")] + pub tx: bitcoin::Transaction, + pub height: Option, + pub time: Option, +} + #[cfg(test)] mod tests { use super::*; - use crate::testutils::*; + use crate::{bitcoin::Block, database::SpendBlock, testutils::*}; + use bitcoin::{ + blockdata::transaction::{TxIn, TxOut}, + util::bip32::ChildNumber, + OutPoint, PackedLockTime, Script, Sequence, Transaction, Txid, Witness, + }; use std::str::FromStr; use bitcoin::util::bip32; #[test] fn getinfo() { - let ms = DummyLiana::new(); + let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new()); // We can query getinfo ms.handle.control.get_info(); ms.shutdown(); @@ -640,7 +687,7 @@ mod tests { #[test] fn getnewaddress() { - let ms = DummyLiana::new(); + let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new()); let control = &ms.handle.control; // We can get an address @@ -661,7 +708,7 @@ mod tests { #[test] fn create_spend() { - let ms = DummyLiana::new(); + let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new()); let control = &ms.handle.control; // Arguments sanity checking @@ -777,7 +824,7 @@ mod tests { #[test] fn update_spend() { - let ms = DummyLiana::new(); + let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new()); let control = &ms.handle.control; let mut db_conn = control.db().lock().unwrap().connection(); @@ -888,4 +935,231 @@ mod tests { ms.shutdown(); } + + #[test] + fn list_confirmed_transactions() { + let outpoint = OutPoint::new( + Txid::from_str("617eab1fc0b03ee7f82ba70166725291783461f1a0e7975eaf8b5f8f674234f3") + .unwrap(), + 0, + ); + + let deposit1: Transaction = Transaction { + version: 1, + lock_time: PackedLockTime(1), + input: vec![TxIn { + witness: Witness::new(), + previous_output: outpoint, + script_sig: Script::new(), + sequence: Sequence(0), + }], + output: vec![TxOut { + script_pubkey: Script::new(), + value: 100_000_000, + }], + }; + + let deposit2: Transaction = Transaction { + version: 1, + lock_time: PackedLockTime(1), + input: vec![TxIn { + witness: Witness::new(), + previous_output: outpoint, + script_sig: Script::new(), + sequence: Sequence(0), + }], + output: vec![TxOut { + script_pubkey: Script::new(), + value: 2000, + }], + }; + + let deposit3: Transaction = Transaction { + version: 1, + lock_time: PackedLockTime(1), + input: vec![TxIn { + witness: Witness::new(), + previous_output: outpoint, + script_sig: Script::new(), + sequence: Sequence(0), + }], + output: vec![TxOut { + script_pubkey: Script::new(), + value: 3000, + }], + }; + + let spend_tx: Transaction = Transaction { + version: 1, + lock_time: PackedLockTime(1), + input: vec![TxIn { + witness: Witness::new(), + previous_output: OutPoint { + txid: deposit1.txid(), + vout: 0, + }, + script_sig: Script::new(), + sequence: Sequence(0), + }], + output: vec![ + TxOut { + script_pubkey: Script::new(), + value: 4000, + }, + TxOut { + script_pubkey: Script::new(), + value: 100_000_000 - 4000 - 1000, + }, + ], + }; + + let mut db = DummyDatabase::new(); + db.insert_coins(vec![ + // Deposit 1 + Coin { + is_change: false, + outpoint: OutPoint { + txid: deposit1.txid(), + vout: 0, + }, + block_time: Some(1), + block_height: Some(1), + spend_block: Some(SpendBlock { time: 3, height: 3 }), + derivation_index: ChildNumber::from(0), + amount: bitcoin::Amount::from_sat(100_000_000), + spend_txid: Some(spend_tx.txid()), + }, + // Deposit 2 + Coin { + is_change: false, + outpoint: OutPoint { + txid: deposit2.txid(), + vout: 0, + }, + block_time: Some(2), + block_height: Some(2), + spend_block: None, + derivation_index: ChildNumber::from(1), + amount: bitcoin::Amount::from_sat(2000), + spend_txid: None, + }, + // This coin is a change output. + Coin { + is_change: true, + outpoint: OutPoint::new(spend_tx.txid(), 1), + block_time: Some(3), + block_height: Some(3), + spend_block: None, + derivation_index: ChildNumber::from(2), + amount: bitcoin::Amount::from_sat(100_000_000 - 4000 - 1000), + spend_txid: None, + }, + // Deposit 3 + Coin { + is_change: false, + outpoint: OutPoint { + txid: deposit3.txid(), + vout: 0, + }, + block_time: Some(4), + block_height: Some(4), + spend_block: None, + derivation_index: ChildNumber::from(3), + amount: bitcoin::Amount::from_sat(3000), + spend_txid: None, + }, + ]); + + let mut btc = DummyBitcoind::new(); + btc.txs.insert( + deposit1.txid(), + ( + deposit1.clone(), + Some(Block { + hash: bitcoin::BlockHash::from_str( + "0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94", + ) + .unwrap(), + time: 1, + height: 1, + }), + ), + ); + btc.txs.insert( + deposit2.txid(), + ( + deposit2.clone(), + Some(Block { + hash: bitcoin::BlockHash::from_str( + "0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94", + ) + .unwrap(), + time: 2, + height: 2, + }), + ), + ); + btc.txs.insert( + spend_tx.txid(), + ( + spend_tx.clone(), + Some(Block { + hash: bitcoin::BlockHash::from_str( + "0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94", + ) + .unwrap(), + time: 3, + height: 3, + }), + ), + ); + btc.txs.insert( + deposit3.txid(), + ( + deposit3.clone(), + Some(Block { + hash: bitcoin::BlockHash::from_str( + "0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94", + ) + .unwrap(), + time: 4, + height: 4, + }), + ), + ); + + let ms = DummyLiana::new(btc, db); + + let control = &ms.handle.control; + + let transactions = control.list_confirmed_transactions(0, 4, 10).transactions; + assert_eq!(transactions.len(), 4); + + assert_eq!(transactions[0].time, Some(4)); + assert_eq!(transactions[0].tx, deposit3); + + assert_eq!(transactions[1].time, Some(3)); + assert_eq!(transactions[1].tx, spend_tx); + + assert_eq!(transactions[2].time, Some(2)); + assert_eq!(transactions[2].tx, deposit2); + + assert_eq!(transactions[3].time, Some(1)); + assert_eq!(transactions[3].tx, deposit1); + + let transactions = control.list_confirmed_transactions(2, 3, 10).transactions; + assert_eq!(transactions.len(), 2); + + assert_eq!(transactions[0].time, Some(3)); + assert_eq!(transactions[1].time, Some(2)); + assert_eq!(transactions[1].tx, deposit2); + + let transactions = control.list_confirmed_transactions(2, 3, 1).transactions; + assert_eq!(transactions.len(), 1); + + assert_eq!(transactions[0].time, Some(3)); + assert_eq!(transactions[0].tx, spend_tx); + + ms.shutdown(); + } } diff --git a/src/commands/utils.rs b/src/commands/utils.rs index 72d5ada7..b10a95fe 100644 --- a/src/commands/utils.rs +++ b/src/commands/utils.rs @@ -1,6 +1,8 @@ use crate::database::DatabaseConnection; -use miniscript::bitcoin::{self, consensus, util::psbt::PartiallySignedTransaction as Psbt}; +use miniscript::bitcoin::{ + self, consensus, hashes::hex::FromHex, util::psbt::PartiallySignedTransaction as Psbt, +}; use serde::{de, Deserialize, Deserializer, Serializer}; /// Serialize an amount as sats @@ -25,14 +27,32 @@ where s.serialize_str(&base64::encode(consensus::serialize(&t))) } -pub fn deser_psbt_base64<'de, D>(d: D) -> Result +pub fn deser_base64<'de, D, T>(d: D) -> Result where D: Deserializer<'de>, + T: consensus::Decodable, { let s = String::deserialize(d)?; let s = base64::decode(&s).map_err(de::Error::custom)?; - let psbt = consensus::deserialize(&s).map_err(de::Error::custom)?; - Ok(psbt) + consensus::deserialize(&s).map_err(de::Error::custom) +} + +pub fn ser_hex(t: T, s: S) -> Result +where + S: Serializer, + T: consensus::Encodable, +{ + s.serialize_str(&consensus::encode::serialize_hex(&t)) +} + +pub fn deser_hex<'de, D, T>(d: D) -> Result +where + D: Deserializer<'de>, + T: consensus::Decodable, +{ + let s = String::deserialize(d)?; + let s = Vec::from_hex(&s).map_err(de::Error::custom)?; + consensus::deserialize(&s).map_err(de::Error::custom) } // Utility to gather the index of a change output in a Psbt, if there is one. diff --git a/src/database/mod.rs b/src/database/mod.rs index acdfb319..494c6cee 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -117,6 +117,9 @@ pub trait DatabaseConnection { /// Mark the given tip as the new best seen block. Update stored data accordingly. fn rollback_tip(&mut self, new_tip: &BlockChainTip); + + /// Retrieve a limited list of txids that where deposited or spent between the start and end timestamps (inclusive bounds) + fn list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec; } impl DatabaseConnection for SqliteConn { @@ -245,6 +248,10 @@ impl DatabaseConnection for SqliteConn { fn rollback_tip(&mut self, new_tip: &BlockChainTip) { self.rollback_tip(new_tip) } + + fn list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec { + self.db_list_txids(start, end, limit) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index 865dea96..b40df29e 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -490,6 +490,38 @@ impl SqliteConn { .expect("Db must not fail") } + /// Retrieves a limited and ordered list of transactions ids that happened during the given + /// range. + pub fn db_list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec { + db_query( + &mut self.conn, + "SELECT DISTINCT(txid) FROM ( \ + SELECT * from ( \ + SELECT txid, blocktime AS date FROM coins \ + WHERE blocktime >= (?1) \ + AND blocktime <= (?2) \ + ORDER BY blocktime DESC LIMIT (?3) \ + ) \ + UNION \ + SELECT * FROM ( + SELECT spend_txid AS txid, spend_block_time AS date FROM coins \ + WHERE spend_block_time >= (?1) \ + AND spend_block_time <= (?2) \ + ORDER BY spend_block_time DESC LIMIT (?3) \ + ) \ + ORDER BY date DESC LIMIT (?3) \ + )", + rusqlite::params![start, end, limit], + |row| { + let txid: Vec = row.get(0)?; + let txid: bitcoin::Txid = + encode::deserialize(&txid).expect("We only store valid txids"); + Ok(txid) + }, + ) + .expect("Db must not fail") + } + pub fn delete_spend(&mut self, txid: &bitcoin::Txid) { db_exec(&mut self.conn, |db_tx| { db_tx.execute( @@ -1089,4 +1121,157 @@ mod tests { fs::remove_dir_all(&tmp_dir).unwrap(); } + + #[test] + fn sqlite_list_txids() { + let (tmp_dir, _, _, db) = dummy_db(); + + { + let mut conn = db.connection().unwrap(); + + 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(), + is_change: false, + spend_txid: None, + spend_block: None, + }, + Coin { + outpoint: bitcoin::OutPoint::from_str( + "c449539458c60bee6c0d8905ba1dadb20b9187b82045d306a408b894cea492b0:2", + ) + .unwrap(), + block_height: Some(101_095), + block_time: Some(1_121_000), + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(100).unwrap(), + is_change: false, + spend_txid: None, + spend_block: None, + }, + Coin { + outpoint: bitcoin::OutPoint::from_str( + "f0801fd9ca8bca0624c230ab422b2e2c4c8dc995e4e1dbc6412510959cce1e4f:3", + ) + .unwrap(), + block_height: Some(101_099), + block_time: Some(1_122_000), + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(1000).unwrap(), + is_change: false, + spend_txid: Some( + bitcoin::Txid::from_str( + "0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7", + ) + .unwrap(), + ), + spend_block: Some(SpendBlock { + height: 101_199, + time: 1_123_000, + }), + }, + Coin { + outpoint: bitcoin::OutPoint::from_str( + "19f56e65069f0a7a3bfb00c6a7085cc0669e03e91befeca1ee9891c9e737b2fb:4", + ) + .unwrap(), + block_height: Some(101_100), + block_time: Some(1_124_000), + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(10000).unwrap(), + is_change: false, + spend_txid: None, + spend_block: None, + }, + Coin { + outpoint: bitcoin::OutPoint::from_str( + "ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9:5", + ) + .unwrap(), + block_height: Some(101_102), + block_time: Some(1_125_000), + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(100000).unwrap(), + is_change: false, + spend_txid: Some( + bitcoin::Txid::from_str( + "7477017f992cdc7ba08acafb77cb3b5bc0f42ac340d3e1e1da0785bdda20d5f6", + ) + .unwrap(), + ), + spend_block: Some(SpendBlock { + height: 101_105, + time: 1_126_000, + }), + }, + ]; + 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 db_txids = conn.db_list_txids(1_123_000, 1_127_000, 10); + assert_eq!( + &db_txids[..], + &[ + bitcoin::Txid::from_str( + "7477017f992cdc7ba08acafb77cb3b5bc0f42ac340d3e1e1da0785bdda20d5f6" + ) + .unwrap(), + bitcoin::Txid::from_str( + "ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9" + ) + .unwrap(), + bitcoin::Txid::from_str( + "19f56e65069f0a7a3bfb00c6a7085cc0669e03e91befeca1ee9891c9e737b2fb" + ) + .unwrap(), + bitcoin::Txid::from_str( + "0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7" + ) + .unwrap() + ] + ); + + let db_txids = conn.db_list_txids(1_123_000, 1_127_000, 2); + assert_eq!( + &db_txids[..], + &[ + bitcoin::Txid::from_str( + "7477017f992cdc7ba08acafb77cb3b5bc0f42ac340d3e1e1da0785bdda20d5f6" + ) + .unwrap(), + bitcoin::Txid::from_str( + "ed6c8f1af9325f84de521e785e7ddfd33dc28c9ada4d687dcd3850100bde54e9" + ) + .unwrap(), + ] + ); + } + + fs::remove_dir_all(&tmp_dir).unwrap(); + } } diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index 42761047..5b0985a9 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -84,6 +84,35 @@ fn broadcast_spend(control: &DaemonControl, params: Params) -> Result Result { + let start: u32 = params + .get(0, "start") + .ok_or_else(|| Error::invalid_params("Missing 'start' parameter."))? + .as_i64() + .and_then(|i| i.try_into().ok()) + .ok_or_else(|| Error::invalid_params("Invalid 'start' parameter."))?; + + let end: u32 = params + .get(1, "end") + .ok_or_else(|| Error::invalid_params("Missing 'end' parameter."))? + .as_i64() + .and_then(|i| i.try_into().ok()) + .ok_or_else(|| Error::invalid_params("Invalid 'end' parameter."))?; + + let limit: u64 = params + .get(2, "limit") + .ok_or_else(|| Error::invalid_params("Missing 'limit' parameter."))? + .as_i64() + .and_then(|i| i.try_into().ok()) + .ok_or_else(|| Error::invalid_params("Invalid 'limit' parameter."))?; + + Ok(serde_json::json!(&control.list_confirmed_transactions( + start as u32, + end as u32, + limit + ))) +} + fn start_rescan(control: &DaemonControl, params: Params) -> Result { let timestamp: u32 = params .get(0, "timestamp") @@ -136,6 +165,14 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result { + let params = req.params.ok_or_else(|| { + Error::invalid_params( + "The 'listconfirmed' command requires 3 parameters: 'start', 'end' and 'limit'", + ) + })?; + list_confirmed(control, params)? + } _ => { return Err(Error::method_not_found()); } diff --git a/src/jsonrpc/server.rs b/src/jsonrpc/server.rs index d39fe98a..dc6f7ca1 100644 --- a/src/jsonrpc/server.rs +++ b/src/jsonrpc/server.rs @@ -400,7 +400,7 @@ mod tests { #[cfg(not(target_os = "macos"))] #[test] fn server_sanity_check() { - let ms = DummyLiana::new(); + let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new()); let socket_path: path::PathBuf = [ ms.tmp_dir.as_path(), path::Path::new("d"), diff --git a/src/testutils.rs b/src/testutils.rs index 745d4a2e..c42a743a 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -1,5 +1,5 @@ use crate::{ - bitcoin::{BitcoinInterface, BlockChainTip, UTxO}, + bitcoin::{BitcoinInterface, Block, BlockChainTip, UTxO}, config::{BitcoinConfig, Config}, database::{Coin, DatabaseConnection, DatabaseInterface, SpendBlock}, descriptors, DaemonHandle, @@ -11,11 +11,24 @@ use miniscript::{ bitcoin::{ self, secp256k1, util::{bip32, psbt::PartiallySignedTransaction as Psbt}, + Transaction, Txid, }, descriptor, }; -pub struct DummyBitcoind {} +pub struct DummyBitcoind { + pub txs: HashMap)>, +} + +impl DummyBitcoind {} + +impl DummyBitcoind { + pub fn new() -> Self { + Self { + txs: HashMap::new(), + } + } +} impl BitcoinInterface for DummyBitcoind { fn genesis_block(&self) -> BlockChainTip { @@ -63,7 +76,7 @@ impl BitcoinInterface for DummyBitcoind { fn spent_coins( &self, _: &[(bitcoin::OutPoint, bitcoin::Txid)], - ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, i32, u32)> { + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid, Block)> { Vec::new() } @@ -90,9 +103,16 @@ impl BitcoinInterface for DummyBitcoind { fn tip_time(&self) -> u32 { todo!() } + + fn wallet_transaction( + &self, + txid: &bitcoin::Txid, + ) -> Option<(bitcoin::Transaction, Option)> { + self.txs.get(txid).cloned() + } } -pub struct DummyDb { +struct DummyDbState { deposit_index: bip32::ChildNumber, change_index: bip32::ChildNumber, curr_tip: Option, @@ -100,35 +120,39 @@ pub struct DummyDb { spend_txs: HashMap, } -impl DummyDb { - pub fn new() -> DummyDb { - DummyDb { - deposit_index: 0.into(), - change_index: 0.into(), - curr_tip: None, - coins: HashMap::new(), - spend_txs: HashMap::new(), +pub struct DummyDatabase { + db: sync::Arc>, +} + +impl DatabaseInterface for DummyDatabase { + fn connection(&self) -> Box { + Box::new(DummyDatabase { + db: self.db.clone(), + }) + } +} + +impl DummyDatabase { + pub fn new() -> DummyDatabase { + DummyDatabase { + db: sync::Arc::new(sync::RwLock::new(DummyDbState { + deposit_index: 0.into(), + change_index: 0.into(), + curr_tip: None, + coins: HashMap::new(), + spend_txs: HashMap::new(), + })), + } + } + + pub fn insert_coins(&mut self, coins: Vec) { + for coin in coins { + self.db.write().unwrap().coins.insert(coin.outpoint, coin); } } } -impl Default for DummyDb { - fn default() -> DummyDb { - DummyDb::new() - } -} - -impl DatabaseInterface for sync::Arc> { - fn connection(&self) -> Box { - Box::new(DummyDbConn { db: self.clone() }) - } -} - -pub struct DummyDbConn { - db: sync::Arc>, -} - -impl DatabaseConnection for DummyDbConn { +impl DatabaseConnection for DummyDatabase { fn network(&mut self) -> bitcoin::Network { bitcoin::Network::Bitcoin } @@ -284,6 +308,35 @@ impl DatabaseConnection for DummyDbConn { fn complete_rescan(&mut self) { todo!() } + + fn list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec { + let mut txids_and_time = Vec::new(); + let coins = &self.db.read().unwrap().coins; + // Get txid and block time of every transactions that happened between start and end + // timestamps. + for coin in coins.values() { + if let Some(time) = coin.block_time { + if time >= start && time <= end { + let row = (coin.outpoint.txid, time); + if !txids_and_time.contains(&row) { + txids_and_time.push(row); + } + } + } + if let Some(time) = coin.spend_block.map(|b| b.time) { + if time >= start && time <= end { + let row = (coin.spend_txid.expect("spent_at is not none"), time); + if !txids_and_time.contains(&row) { + txids_and_time.push(row); + } + } + } + } + // Apply order and limit + txids_and_time.sort_by(|(_, t1), (_, t2)| t2.cmp(t1)); + txids_and_time.truncate(limit as usize); + txids_and_time.into_iter().map(|(txid, _)| txid).collect() + } } pub struct DummyLiana { @@ -310,7 +363,11 @@ pub fn tmp_dir() -> path::PathBuf { } impl DummyLiana { - pub fn new() -> DummyLiana { + /// Creates a new DummyLiana interface + pub fn new( + bitcoin_interface: impl BitcoinInterface + 'static, + database: impl DatabaseInterface + 'static, + ) -> DummyLiana { let tmp_dir = tmp_dir(); fs::create_dir_all(&tmp_dir).unwrap(); // Use a shorthand for 'datadir', to avoid overflowing SUN_LEN on MacOS. @@ -336,8 +393,7 @@ impl DummyLiana { main_descriptor: desc, }; - let db = sync::Arc::from(sync::RwLock::from(DummyDb::new())); - let handle = DaemonHandle::start(config, Some(DummyBitcoind {}), Some(db)).unwrap(); + let handle = DaemonHandle::start(config, Some(bitcoin_interface), Some(database)).unwrap(); DummyLiana { tmp_dir, handle } } From 774f9695941757253ba34c9a61c09e70d4a3149f Mon Sep 17 00:00:00 2001 From: edouard Date: Tue, 22 Nov 2022 15:18:41 +0100 Subject: [PATCH 2/4] Add listtransaction command --- doc/API.md | 17 ++++++ src/commands/mod.rs | 142 ++++++++++++++++++++++++++++++++++++++++++++ src/jsonrpc/api.rs | 22 +++++++ 3 files changed, 181 insertions(+) diff --git a/doc/API.md b/doc/API.md index db4187d9..e94b480b 100644 --- a/doc/API.md +++ b/doc/API.md @@ -16,6 +16,7 @@ Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`. | [`broadcastspend`](#broadcastspend) | Finalize a stored Spend PSBT, and broadcast it | | [`startrescan`](#startrescan) | Start rescanning the block chain from a given date | | [`listconfirmed`](#listconfirmed) | List of confirmed transactions of incoming and outgoing funds | +| [`listtransactions`](#listtransactions) | List of transactions with the given txids | # Reference @@ -244,3 +245,19 @@ Confirmation time is based on the timestamp of blocks. | `height` | int or `null` | Block height of the transaction, `null` if the transaction is unconfirmed | | `time` | int or `null` | Block time of the transaction, `null` if the transaction is unconfirmed | | `tx` | string | hex encoded bitcoin transaction | + +### `listtransactions` + +`listtransactions` retrieves the transactions with the given txids. + +#### Request + +| Field | Type | Description | +| ------------- | --------------- | ------------------------------------- | +| `txids` | array of string | Ids of the transactions to retrieve | + +#### Response + +| Field | Type | Description | +| -------------- | ------ | ------------------------------------------------------ | +| `transactions` | array | Array of [Transaction resource](#transaction-resource) | diff --git a/src/commands/mod.rs b/src/commands/mod.rs index feed033e..5b4ea22d 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -584,6 +584,25 @@ impl DaemonControl { .collect(); ListTransactionsResult { transactions } } + + /// list_transactions retrieves the transactions with the given txids. + pub fn list_transactions(&self, txids: &[bitcoin::Txid]) -> ListTransactionsResult { + let transactions = txids + .iter() + .filter_map(|txid| { + // TODO: batch batch those calls to the Bitcoin backend + // so it can in turn optimize its queries. + self.bitcoin + .wallet_transaction(txid) + .map(|(tx, block)| TransactionInfo { + tx, + height: block.map(|b| b.height), + time: block.map(|b| b.time), + }) + }) + .collect(); + ListTransactionsResult { transactions } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -668,6 +687,7 @@ pub struct TransactionInfo { mod tests { use super::*; use crate::{bitcoin::Block, database::SpendBlock, testutils::*}; + use bitcoin::{ blockdata::transaction::{TxIn, TxOut}, util::bip32::ChildNumber, @@ -1162,4 +1182,126 @@ mod tests { ms.shutdown(); } + + #[test] + fn list_transactions() { + let outpoint = OutPoint::new( + Txid::from_str("617eab1fc0b03ee7f82ba70166725291783461f1a0e7975eaf8b5f8f674234f3") + .unwrap(), + 0, + ); + + let tx1: Transaction = Transaction { + version: 1, + lock_time: PackedLockTime(1), + input: vec![TxIn { + witness: Witness::new(), + previous_output: outpoint, + script_sig: Script::new(), + sequence: Sequence(0), + }], + output: vec![TxOut { + script_pubkey: Script::new(), + value: 100_000_000, + }], + }; + + let tx2: Transaction = Transaction { + version: 1, + lock_time: PackedLockTime(1), + input: vec![TxIn { + witness: Witness::new(), + previous_output: outpoint, + script_sig: Script::new(), + sequence: Sequence(0), + }], + output: vec![TxOut { + script_pubkey: Script::new(), + value: 2000, + }], + }; + + let tx3: Transaction = Transaction { + version: 1, + lock_time: PackedLockTime(1), + input: vec![TxIn { + witness: Witness::new(), + previous_output: outpoint, + script_sig: Script::new(), + sequence: Sequence(0), + }], + output: vec![TxOut { + script_pubkey: Script::new(), + value: 3000, + }], + }; + + let mut btc = DummyBitcoind::new(); + btc.txs.insert( + tx1.txid(), + ( + tx1.clone(), + Some(Block { + hash: bitcoin::BlockHash::from_str( + "0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94", + ) + .unwrap(), + time: 1, + height: 1, + }), + ), + ); + btc.txs.insert( + tx2.txid(), + ( + tx2.clone(), + Some(Block { + hash: bitcoin::BlockHash::from_str( + "0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94", + ) + .unwrap(), + time: 2, + height: 2, + }), + ), + ); + btc.txs.insert( + tx3.txid(), + ( + tx3.clone(), + Some(Block { + hash: bitcoin::BlockHash::from_str( + "0000000000000000000326b8fca8d3f820647c97ea33ef722096b3c7b2c8ee94", + ) + .unwrap(), + time: 4, + height: 4, + }), + ), + ); + + let ms = DummyLiana::new(btc, DummyDatabase::new()); + + let control = &ms.handle.control; + + let transactions = control.list_transactions(&[tx1.txid()]).transactions; + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].tx, tx1); + + let transactions = control + .list_transactions(&[tx1.txid(), tx2.txid(), tx3.txid()]) + .transactions; + assert_eq!(transactions.len(), 3); + + let txs: Vec = transactions + .iter() + .map(|transaction| transaction.tx.clone()) + .collect(); + + assert!(txs.contains(&tx1)); + assert!(txs.contains(&tx2)); + assert!(txs.contains(&tx3)); + + ms.shutdown(); + } } diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index 5b0985a9..82886372 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -113,6 +113,20 @@ fn list_confirmed(control: &DaemonControl, params: Params) -> Result Result { + let txids: Vec = params + .get(0, "txids") + .ok_or_else(|| Error::invalid_params("Missing 'txids' parameter."))? + .as_array() + .and_then(|arr| { + arr.iter() + .map(|entry| entry.as_str().and_then(|e| bitcoin::Txid::from_str(e).ok())) + .collect() + }) + .ok_or_else(|| Error::invalid_params("Invalid 'txids' parameter."))?; + Ok(serde_json::json!(&control.list_transactions(&txids))) +} + fn start_rescan(control: &DaemonControl, params: Params) -> Result { let timestamp: u32 = params .get(0, "timestamp") @@ -173,6 +187,14 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result { + let params = req.params.ok_or_else(|| { + Error::invalid_params( + "The 'listtransactions' command requires 1 parameter: 'txids'", + ) + })?; + list_transactions(control, params)? + } _ => { return Err(Error::method_not_found()); } From 1f06c4d4dc4a6c611231c2e95a1e464e038b2570 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Tue, 22 Nov 2022 18:30:54 +0100 Subject: [PATCH 3/4] db: fix the list_txids query We need to limit the number of *distinct* txids. Limiting the number of results in the inner queries at all could lead to incorrect results with regard to ordering too. See https://github.com/revault/liana/pull/99#discussion_r1029619579. --- src/database/sqlite/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index b40df29e..d5233b69 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -500,14 +500,14 @@ impl SqliteConn { SELECT txid, blocktime AS date FROM coins \ WHERE blocktime >= (?1) \ AND blocktime <= (?2) \ - ORDER BY blocktime DESC LIMIT (?3) \ + ORDER BY blocktime \ ) \ UNION \ SELECT * FROM ( SELECT spend_txid AS txid, spend_block_time AS date FROM coins \ WHERE spend_block_time >= (?1) \ AND spend_block_time <= (?2) \ - ORDER BY spend_block_time DESC LIMIT (?3) \ + ORDER BY spend_block_time \ ) \ ORDER BY date DESC LIMIT (?3) \ )", From c39cb07360e5d3a6f528d4b17f1e34fae0da550b Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Tue, 22 Nov 2022 18:31:29 +0100 Subject: [PATCH 4/4] qa: functional test for transaction listing commands --- tests/test_rpc.py | 176 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 171 insertions(+), 5 deletions(-) diff --git a/tests/test_rpc.py b/tests/test_rpc.py index d13ce180..6e8488a0 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -1,7 +1,5 @@ -import os import pytest import random -import shutil import time from fixtures import * @@ -47,9 +45,7 @@ def test_listcoins(lianad, bitcoind): # If the coin gets confirmed, it'll be marked as such. bitcoind.generate_block(1, wait_for_mempool=txid) block_height = bitcoind.rpc.getblockcount() - wait_for( - lambda: lianad.rpc.listcoins()["coins"][0]["block_height"] == block_height - ) + wait_for(lambda: lianad.rpc.listcoins()["coins"][0]["block_height"] == block_height) # Same if the coin gets spent. spend_tx = spend_coins(lianad, bitcoind, (res[0],)) @@ -356,3 +352,173 @@ def test_start_rescan(lianad, bitcoind): # Now that it caught up it noticed which one were used onchain, so it won't reuse # this derivation indexes anymore. assert lianad.rpc.getnewaddress() not in (first_address, second_address) + + +def test_listtransactions(lianad, bitcoind): + """Test listing of transactions by txid and timespan""" + + def sign_and_broadcast(psbt): + txid = psbt.tx.txid().hex() + psbt = lianad.sign_psbt(psbt) + lianad.rpc.updatespend(psbt.to_base64()) + lianad.rpc.broadcastspend(txid) + return txid + + def wait_synced(): + wait_for( + lambda: lianad.rpc.getinfo()["blockheight"] == bitcoind.rpc.getblockcount() + ) + + best_block = bitcoind.rpc.getbestblockhash() + initial_timestamp = bitcoind.rpc.getblockheader(best_block)["time"] + wait_synced() + + # Deposit multiple coins in a single transaction + destinations = { + lianad.rpc.getnewaddress()["address"]: 0.0123456, + lianad.rpc.getnewaddress()["address"]: 0.0123457, + lianad.rpc.getnewaddress()["address"]: 0.0123458, + } + txid = bitcoind.rpc.sendmany("", destinations) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 3) + bitcoind.generate_block(1, wait_for_mempool=txid) + + # Mine 12 blocks to force the blocktime to increase + bitcoind.generate_block(12) + wait_synced() + best_block = bitcoind.rpc.getbestblockhash() + second_timestamp = bitcoind.rpc.getblockheader(best_block)["time"] + assert second_timestamp > initial_timestamp + + # Deposit a coin that will be unspent + addr = lianad.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, 0.123456) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 4) + bitcoind.generate_block(1, wait_for_mempool=txid) + + # Deposit a coin that will be spent with a change output + addr = lianad.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, 0.23456) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 5) + bitcoind.generate_block(1, wait_for_mempool=txid) + outpoint = next( + c["outpoint"] for c in lianad.rpc.listcoins()["coins"] if txid in c["outpoint"] + ) + destinations = { + bitcoind.rpc.getnewaddress(): 100_000, + } + res = lianad.rpc.createspend(destinations, [outpoint], 6) + psbt = PSBT.from_base64(res["psbt"]) + txid = sign_and_broadcast(psbt) + bitcoind.generate_block(1, wait_for_mempool=txid) + + # Mine 12 blocks to force the blocktime to increase + bitcoind.generate_block(12) + wait_synced() + best_block = bitcoind.rpc.getbestblockhash() + third_timestamp = bitcoind.rpc.getblockheader(best_block)["time"] + assert third_timestamp > second_timestamp + bitcoind.generate_block(12) + wait_synced() + + # Deposit a coin that will be spent with a change output and also two new deposits + addr = lianad.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, 0.3456) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 7) + bitcoind.generate_block(1, wait_for_mempool=txid) + outpoint = next( + c["outpoint"] for c in lianad.rpc.listcoins()["coins"] if txid in c["outpoint"] + ) + destinations = { + bitcoind.rpc.getnewaddress(): 11_000, + addr: 12_000, # Even with address reuse! Booooh + lianad.rpc.getnewaddress()["address"]: 13_000, + } + res = lianad.rpc.createspend(destinations, [outpoint], 6) + psbt = PSBT.from_base64(res["psbt"]) + txid = sign_and_broadcast(psbt) + bitcoind.generate_block(1, wait_for_mempool=txid) + + # Deposit a coin that will be spending (unconfirmed spend transaction) + addr = lianad.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, 0.456) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 11) + bitcoind.generate_block(1, wait_for_mempool=txid) + outpoint = next( + c["outpoint"] for c in lianad.rpc.listcoins()["coins"] if txid in c["outpoint"] + ) + destinations = { + bitcoind.rpc.getnewaddress(): 11_000, + } + res = lianad.rpc.createspend(destinations, [outpoint], 6) + psbt = PSBT.from_base64(res["psbt"]) + txid = sign_and_broadcast(psbt) + + # At this point we have 12 spent and unspent coins, one of them is unconfirmed. + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 12) + + # However some of them share the same txid! This is the case of the 3 first coins + # for instance, or the Spend transactions with multiple outputs at one of our addresses. + # In total, that's 8 transactions. + txids = set(c["outpoint"][:-2] for c in lianad.rpc.listcoins()["coins"]) + assert len(txids) == 8 + + # We can query all of them at once using listtransactions. The result contains all + # the correct transactions as hex, with no duplicate. + all_txs = lianad.rpc.listtransactions(list(txids))["transactions"] + assert len(all_txs) == 8 + for tx in all_txs: + txid = bitcoind.rpc.decoderawtransaction(tx["tx"])["txid"] + txids.remove(txid) # This will raise an error if it isn't there + + # We can also query them one by one. + txids = set(c["outpoint"][:-2] for c in lianad.rpc.listcoins()["coins"]) + for txid in txids: + txs = lianad.rpc.listtransactions([txid])["transactions"] + bit_txid = bitcoind.rpc.decoderawtransaction(txs[0]["tx"])["txid"] + assert bit_txid == txid + + # We can query all confirmed transactions + best_block = bitcoind.rpc.getbestblockhash() + final_timestamp = bitcoind.rpc.getblockheader(best_block)["time"] + txs = lianad.rpc.listconfirmed(initial_timestamp, final_timestamp, 10)[ + "transactions" + ] + assert len(txs) == 7, "The last spend tx is unconfirmed" + for tx in txs: + txid = bitcoind.rpc.decoderawtransaction(tx["tx"])["txid"] + txids.remove(txid) # This will raise an error if it isn't there + + # We can limit the size of the result + txs = lianad.rpc.listconfirmed(initial_timestamp, final_timestamp, 5)[ + "transactions" + ] + assert len(txs) == 5 + + # We can restrict the query to a certain time window. + # First get the txid of all the transactions that happened during this timespan. + txids = set() + for coin in lianad.rpc.listcoins()["coins"]: + if coin["block_height"] is None: + continue + block_hash = bitcoind.rpc.getblockhash(coin["block_height"]) + block_time = bitcoind.rpc.getblockheader(block_hash)["time"] + spend_time = None + if coin["spend_info"] is not None and coin["spend_info"]["height"] is not None: + spend_bhash = bitcoind.rpc.getblockhash(coin["spend_info"]["height"]) + spend_time = bitcoind.rpc.getblockheader(spend_bhash)["time"] + if (block_time >= second_timestamp and block_time <= third_timestamp) or ( + spend_time is not None + and spend_time >= second_timestamp + and spend_time <= third_timestamp + ): + txids.add(coin["outpoint"][:-2]) + # It's all 7 minus the first deposit and the last confirmed spend. So that's 5 of them. + assert len(txids) == 3 + # Now let's compare with what lianad is giving us. + txs = lianad.rpc.listconfirmed(second_timestamp, third_timestamp, 10)[ + "transactions" + ] + assert len(txs) == 3 + bit_txids = set(bitcoind.rpc.decoderawtransaction(tx["tx"])["txid"] for tx in txs) + assert bit_txids == txids