bitcoin: interface for coins discovery and updates

This commit is contained in:
Antoine Poinsot 2022-08-15 14:07:27 +02:00
parent 05b3af1b5a
commit c6a25adfcd
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
3 changed files with 359 additions and 4 deletions

View File

@ -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<serde_json::value::RawValue>],
) -> Result<Json, BitcoindError> {
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",
&params!(Json::String(block_hash.to_string()),),
)
.into()
}
pub fn get_transaction(&self, txid: &bitcoin::Txid) -> Option<GetTxRes> {
// TODO: Maybe assert we got a -5 error, and not any other kind of error?
self.make_faillible_wallet_request(
"gettransaction",
&params!(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",
&params!(
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<bitcoin::Txid> {
// Get the hash of the block parent of the spent transaction's block.
let req = self.make_wallet_request(
"gettransaction",
&params!(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",
&params!(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", &params!(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",
&params!(
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<i32>,
}
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<LSBlockEntry>,
}
impl From<Json> 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<i32>,
}
impl From<Json> 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 }
}
}

View File

@ -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<UTxO>;
/// 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<UTxO> {
// 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<sync::Mutex<dyn BitcoinInterface + 'static>> {
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<sync::Mutex<dyn BitcoinInterface + 'static>>
fn is_in_chain(&self, tip: &BlockChainTip) -> bool {
self.lock().unwrap().is_in_chain(tip)
}
fn received_coins(&self, tip: &BlockChainTip) -> Vec<UTxO> {
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<i32>,
}

View File

@ -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<UTxO> {
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 {