From c6a25adfcdd371a1a2581cc210fb4dc03dfced2d Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Mon, 15 Aug 2022 14:07:27 +0200 Subject: [PATCH] bitcoin: interface for coins discovery and updates --- src/bitcoin/d/mod.rs | 223 ++++++++++++++++++++++++++++++++++++++++++- src/bitcoin/mod.rs | 118 ++++++++++++++++++++++- src/testutils.rs | 22 ++++- 3 files changed, 359 insertions(+), 4 deletions(-) diff --git a/src/bitcoin/d/mod.rs b/src/bitcoin/d/mod.rs index a10152bc..ad62c48d 100644 --- a/src/bitcoin/d/mod.rs +++ b/src/bitcoin/d/mod.rs @@ -3,7 +3,7 @@ ///! We use the RPC interface and a watchonly descriptor wallet. use crate::{bitcoin::BlockChainTip, config}; -use std::{convert::TryInto, fs, io, str::FromStr, time::Duration}; +use std::{collections::HashSet, convert::TryInto, fs, io, str::FromStr, time::Duration}; use jsonrpc::{ arg, @@ -278,6 +278,14 @@ impl BitcoinD { .expect("We must not fail to make a request for more than a minute") } + fn make_faillible_wallet_request( + &self, + method: &str, + params: &[Box], + ) -> Result { + self.make_request(&self.watchonly_client, method, params) + } + fn get_bitcoind_version(&self) -> u64 { self.make_node_request("getnetworkinfo", &[]) .get("version") @@ -521,8 +529,134 @@ impl BitcoinD { .expect("bitcoind must send valid block hashes"), ) } -} + pub fn list_since_block(&self, block_hash: &bitcoin::BlockHash) -> LSBlockRes { + self.make_wallet_request( + "listsinceblock", + ¶ms!(Json::String(block_hash.to_string()),), + ) + .into() + } + + pub fn get_transaction(&self, txid: &bitcoin::Txid) -> Option { + // TODO: Maybe assert we got a -5 error, and not any other kind of error? + self.make_faillible_wallet_request( + "gettransaction", + ¶ms!(Json::String(txid.to_string())), + ) + .ok() + .map(|res| res.into()) + } + + /// Efficient check that a coin is spent. + pub fn is_spent(&self, op: &bitcoin::OutPoint) -> bool { + // The result of gettxout is empty if the outpoint is spent. + self.make_node_request( + "gettxout", + ¶ms!( + Json::String(op.txid.to_string()), + Json::Number(op.vout.into()) + ), + ) + .get("bestblock") + .is_none() + } + + /// So, bitcoind has no API for getting the transaction spending a wallet UTXO. Instead we are + /// therefore using a rather convoluted way to get it the other way around, since the spending + /// transaction is actually *part of the wallet transactions*. + /// So, what we do there is listing all outgoing transactions of the wallet since the last poll + /// and iterating through each of those to check if it spends the transaction we are interested + /// in (requiring an other RPC call for each!!). + pub fn get_spender_txid(&self, spent_outpoint: &bitcoin::OutPoint) -> Option { + // Get the hash of the block parent of the spent transaction's block. + let req = self.make_wallet_request( + "gettransaction", + ¶ms!(Json::String(spent_outpoint.txid.to_string())), + ); + let spent_tx_height = match req.get("blockheight").and_then(Json::as_i64) { + Some(h) => h, + // FIXME: we assume it's confirmed. If we were to change the logic in the poller, we'd + // need to handle it here. + None => return None, + }; + let block_hash = if let Ok(res) = self.make_fallible_node_request( + "getblockhash", + ¶ms!(Json::Number((spent_tx_height - 1).into())), + ) { + res.as_str() + .expect("'getblockhash' result isn't a string") + .to_string() + } else { + // Possibly a race. + return None; + }; + + // Now we can get all transactions related to us since the spent transaction confirmed. + // We'll use it to locate the spender. + let lsb_res = + self.make_wallet_request("listsinceblock", ¶ms!(Json::String(block_hash))); + let transactions = lsb_res + .get("transactions") + .and_then(Json::as_array) + .expect("tx array must be there"); + + // Get the spent txid to ignore the entries about this transaction + let spent_txid = spent_outpoint.txid.to_string(); + // We use a cache to avoid needless iterations, since listsinceblock returns an entry + // per transaction output, not per transaction. + let mut visited_txs = HashSet::with_capacity(transactions.len()); + for transaction in transactions { + if transaction.get("category").and_then(Json::as_str) != Some("send") { + continue; + } + + let spending_txid = transaction + .get("txid") + .and_then(Json::as_str) + .expect("A valid txid must be present"); + if visited_txs.contains(&spending_txid) || &spent_txid == spending_txid { + continue; + } else { + visited_txs.insert(spending_txid); + } + + let gettx_res = self.make_wallet_request( + "gettransaction", + ¶ms!( + Json::String(spending_txid.to_string()), + Json::Bool(true), // watchonly + Json::Bool(true) // verbose + ), + ); + let vin = gettx_res + .get("decoded") + .and_then(|d| d.get("vin").and_then(Json::as_array)) + .expect("A valid vin array must be present"); + + for input in vin { + let txid = input + .get("txid") + .and_then(Json::as_str) + .and_then(|t| bitcoin::Txid::from_str(t).ok()) + .expect("A valid txid must be present"); + let vout = input + .get("vout") + .and_then(Json::as_u64) + .expect("A valid vout must be present") as u32; + let input_outpoint = bitcoin::OutPoint { txid, vout }; + + if spent_outpoint == &input_outpoint { + return bitcoin::Txid::from_str(spending_txid) + .map(Some) + .expect("Must be a valid txid"); + } + } + } + + None + } +} // Bitcoind uses a guess for the value of verificationprogress. It will eventually get to // be 1, and we want to be less conservative. fn roundup_progress(progress: f64) -> f64 { @@ -535,3 +669,88 @@ fn roundup_progress(progress: f64) -> f64 { (progress_rounded as f64 / precision) as f64 } } + +/// A 'received' entry in the 'listsinceblock' result. +#[derive(Debug, Clone, Copy)] +pub struct LSBlockEntry { + pub outpoint: bitcoin::OutPoint, + pub amount: bitcoin::Amount, + pub block_height: Option, +} + +impl From<&Json> for LSBlockEntry { + fn from(json: &Json) -> LSBlockEntry { + let txid = json + .get("txid") + .and_then(Json::as_str) + .and_then(|s| bitcoin::Txid::from_str(s).ok()) + .expect("bitcoind can't give a bad block hash"); + let vout = json + .get("vout") + .and_then(Json::as_u64) + .expect("bitcoind can't give a bad vout") as u32; + let outpoint = bitcoin::OutPoint { txid, vout }; + + // Must be a received entry, hence not negative. + let amount = json + .get("amount") + .and_then(Json::as_f64) + .and_then(|a| bitcoin::Amount::from_btc(a).ok()) + .expect("bitcoind won't give us a bad amount"); + let block_height = json + .get("blockheight") + .and_then(Json::as_i64) + .map(|bh| bh as i32); + + LSBlockEntry { + outpoint, + amount, + block_height, + } + } +} + +#[derive(Debug, Clone)] +pub struct LSBlockRes { + pub received_coins: Vec, +} + +impl From for LSBlockRes { + fn from(json: Json) -> LSBlockRes { + let received_coins = json + .get("transactions") + .and_then(Json::as_array) + .expect("Array must be present") + .into_iter() + .filter_map(|j| { + if j.get("category") + .and_then(Json::as_str) + .expect("must be present") + == "receive" + { + let lsb_entry: LSBlockEntry = j.into(); + Some(lsb_entry) + } else { + None + } + }) + .collect(); + + LSBlockRes { received_coins } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct GetTxRes { + pub block_height: Option, +} + +impl From for GetTxRes { + fn from(json: Json) -> GetTxRes { + let block_height = json + .get("blockheight") + .and_then(Json::as_i64) + .map(|bh| bh as i32); + GetTxRes { block_height } + } +} diff --git a/src/bitcoin/mod.rs b/src/bitcoin/mod.rs index 54406ec3..dc0e8ddf 100644 --- a/src/bitcoin/mod.rs +++ b/src/bitcoin/mod.rs @@ -4,9 +4,11 @@ pub mod d; pub mod poller; +use d::LSBlockEntry; + use std::sync; -use miniscript::bitcoin; +use miniscript::bitcoin::{self, hashes::Hash}; /// Information about the best block in the chain #[derive(Debug, Clone, Eq, PartialEq, Copy)] @@ -17,6 +19,8 @@ pub struct BlockChainTip { /// Our Bitcoin backend. pub trait BitcoinInterface: Send { + fn genesis_block(&self) -> BlockChainTip; + /// Get the progress of the block chain synchronization. /// Returns a percentage between 0 and 1. fn sync_progress(&self) -> f64; @@ -26,9 +30,29 @@ pub trait BitcoinInterface: Send { /// Check whether this former tip is part of the current best chain. fn is_in_chain(&self, tip: &BlockChainTip) -> bool; + + /// Get coins received since the specified tip. + fn received_coins(&self, tip: &BlockChainTip) -> Vec; + + /// Get all coins that were confirmed, and at what height. + fn confirmed_coins(&self, outpoints: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, i32)>; + + /// Get all coins that were spent, and the spending txid. + fn spent_coins( + &self, + outpoints: &[bitcoin::OutPoint], + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid)>; } impl BitcoinInterface for d::BitcoinD { + fn genesis_block(&self) -> BlockChainTip { + let height = 0; + let hash = self + .get_block_hash(height) + .expect("Genesis block hash must always be there"); + BlockChainTip { hash, height } + } + fn sync_progress(&self) -> f64 { self.sync_progress() } @@ -42,10 +66,78 @@ impl BitcoinInterface for d::BitcoinD { .map(|bh| bh == tip.hash) .unwrap_or(false) } + + fn received_coins(&self, tip: &BlockChainTip) -> Vec { + // TODO: don't assume only a single descriptor is loaded on the wo wallet + let lsb_res = self.list_since_block(&tip.hash); + + lsb_res + .received_coins + .into_iter() + .map(|entry| { + let LSBlockEntry { + outpoint, + amount, + block_height, + } = entry; + UTxO { + outpoint, + amount, + block_height, + } + }) + .collect() + } + + fn confirmed_coins(&self, outpoints: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, i32)> { + let mut confirmed = Vec::with_capacity(outpoints.len()); + + 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 { + confirmed.push((*op, h)); + } + } else { + log::error!("Transaction not in wallet for coin '{}'.", op); + } + } + + confirmed + } + + fn spent_coins( + &self, + outpoints: &[bitcoin::OutPoint], + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid)> { + let mut spent = Vec::with_capacity(outpoints.len()); + + for op in outpoints { + if self.is_spent(&op) { + let spending_txid = if let Some(txid) = self.get_spender_txid(&op) { + txid + } else { + // TODO: better handling of this edge case. + log::error!( + "Could not get spender of '{}'. Using a dummy spending txid.", + op + ); + bitcoin::Txid::from_slice(&[0; 32][..]).unwrap() + }; + spent.push((*op, spending_txid)); + } + } + + spent + } } // FIXME: do we need to repeat the entire trait implemenation? Isn't there a nicer way? impl BitcoinInterface for sync::Arc> { + fn genesis_block(&self) -> BlockChainTip { + self.lock().unwrap().genesis_block() + } + fn sync_progress(&self) -> f64 { self.lock().unwrap().sync_progress() } @@ -57,4 +149,28 @@ impl BitcoinInterface for sync::Arc> fn is_in_chain(&self, tip: &BlockChainTip) -> bool { self.lock().unwrap().is_in_chain(tip) } + + fn received_coins(&self, tip: &BlockChainTip) -> Vec { + self.lock().unwrap().received_coins(tip) + } + + fn confirmed_coins(&self, outpoints: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, i32)> { + self.lock().unwrap().confirmed_coins(outpoints) + } + + fn spent_coins( + &self, + outpoints: &[bitcoin::OutPoint], + ) -> Vec<(bitcoin::OutPoint, bitcoin::Txid)> { + self.lock().unwrap().spent_coins(outpoints) + } +} + +// FIXME: We could avoid this type (and all the conversions entailing allocations) if bitcoind +// exposed the derivation index from the parent descriptor in the LSB result. +#[derive(Debug, Clone, Copy)] +pub struct UTxO { + pub outpoint: bitcoin::OutPoint, + pub amount: bitcoin::Amount, + pub block_height: Option, } diff --git a/src/testutils.rs b/src/testutils.rs index c28fc6cb..8df893f4 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -1,5 +1,5 @@ use crate::{ - bitcoin::{BitcoinInterface, BlockChainTip}, + bitcoin::{BitcoinInterface, BlockChainTip, UTxO}, config::{BitcoinConfig, Config}, database::{Coin, DatabaseConnection, DatabaseInterface}, DaemonHandle, @@ -15,6 +15,14 @@ use miniscript::{ pub struct DummyBitcoind {} impl BitcoinInterface for DummyBitcoind { + fn genesis_block(&self) -> BlockChainTip { + let hash = bitcoin::BlockHash::from_str( + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + ) + .unwrap(); + BlockChainTip { hash, height: 0 } + } + fn sync_progress(&self) -> f64 { 1.0 } @@ -32,6 +40,18 @@ impl BitcoinInterface for DummyBitcoind { // No reorg true } + + fn received_coins(&self, _: &BlockChainTip) -> Vec { + Vec::new() + } + + fn confirmed_coins(&self, _: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, i32)> { + Vec::new() + } + + fn spent_coins(&self, _: &[bitcoin::OutPoint]) -> Vec<(bitcoin::OutPoint, bitcoin::Txid)> { + Vec::new() + } } pub struct DummyDb {