diff --git a/doc/API.md b/doc/API.md index 3370b8c4..4d4f6997 100644 --- a/doc/API.md +++ b/doc/API.md @@ -64,3 +64,23 @@ This command does not take any parameter for now. | Field | Type | Description | | ------------- | ------ | ------------------ | | `address` | string | A Bitcoin address | + + +### `listcoins` + +List our current Unspent Transaction Outputs. + +#### Request + +This command does not take any parameter for now. + +| Field | Type | Description | +| ------------- | ----------------- | ----------------------------------------------------------- | + +#### Response + +| Field | Type | Description | +| -------------- | ------------- | ---------------------------------------------------------------- | +| `amount` | int | Value of the UTxO in satoshis | +| `outpoint` | string | Transaction id and output index of this coin | +| `block_height` | int or null | Blockheight the transaction was confirmed at, or `null` | diff --git a/src/bitcoin/d/mod.rs b/src/bitcoin/d/mod.rs index a10152bc..e2edcca2 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,96 @@ fn roundup_progress(progress: f64) -> f64 { (progress_rounded as f64 / precision) as f64 } } + +/// A 'received' entry in the 'listsinceblock' result. +#[derive(Debug, Clone)] +pub struct LSBlockEntry { + pub outpoint: bitcoin::OutPoint, + pub amount: bitcoin::Amount, + pub block_height: Option, + pub address: bitcoin::Address, +} + +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); + + let address = json + .get("address") + .and_then(Json::as_str) + .and_then(|s| bitcoin::Address::from_str(s).ok()) + .expect("bitcoind can't give a bad address"); + + LSBlockEntry { + outpoint, + amount, + block_height, + address, + } + } +} + +#[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..650d5572 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,80 @@ 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, + address, + } = entry; + UTxO { + outpoint, + amount, + block_height, + address, + } + }) + .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 +151,29 @@ 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)] +pub struct UTxO { + pub outpoint: bitcoin::OutPoint, + pub amount: bitcoin::Amount, + pub block_height: Option, + pub address: bitcoin::Address, } diff --git a/src/bitcoin/poller/looper.rs b/src/bitcoin/poller/looper.rs index 6e75d4f5..66e0c2d0 100644 --- a/src/bitcoin/poller/looper.rs +++ b/src/bitcoin/poller/looper.rs @@ -1,6 +1,6 @@ use crate::{ - bitcoin::BitcoinInterface, - database::{DatabaseConnection, DatabaseInterface}, + bitcoin::{BitcoinInterface, BlockChainTip, UTxO}, + database::{Coin, DatabaseConnection, DatabaseInterface}, }; use std::{ @@ -8,32 +8,148 @@ use std::{ thread, time, }; -fn update_tip(bit: &impl BitcoinInterface, db_conn: &mut Box) { +use miniscript::bitcoin; + +#[derive(Debug, Clone)] +struct UpdatedCoins { + pub received: Vec, + pub confirmed: Vec<(bitcoin::OutPoint, i32)>, + pub spent: Vec<(bitcoin::OutPoint, bitcoin::Txid)>, +} + +// Update the state of our coins. There may be new unspent, and existing ones may become confirmed +// or spent. +// NOTE: A coin may be updated multiple times at once. That is, a coin may be received, confirmed, +// and spent in a single poll. +fn update_coins( + bit: &impl BitcoinInterface, + db_conn: &mut Box, + previous_tip: &BlockChainTip, +) -> UpdatedCoins { + // Start by fetching newly received coins. + let curr_coins = db_conn.unspent_coins(); + let mut received = Vec::new(); + for utxo in bit.received_coins(&previous_tip) { + if let Some(derivation_index) = db_conn.derivation_index_by_address(&utxo.address) { + if !curr_coins.contains_key(&utxo.outpoint) { + let UTxO { + outpoint, amount, .. + } = utxo; + let coin = Coin { + outpoint, + amount, + derivation_index, + block_height: None, + spend_txid: None, + }; + received.push(coin); + } + } else { + log::error!( + "Could not get derivation index for coin '{}' (address: '{}')", + &utxo.outpoint, + &utxo.address + ); + } + } + + // We need to take the newly received ones into account as well, as they may have been + // confirmed within the previous tip and the current one, and we may not poll this chunk of the + // chain anymore. + let to_be_confirmed: Vec = curr_coins + .values() + .chain(received.iter()) + .filter_map(|coin| { + if coin.block_height.is_none() { + Some(coin.outpoint) + } else { + None + } + }) + .collect(); + let confirmed = bit.confirmed_coins(&to_be_confirmed); + + // We need to take the newly received ones into account as well, as they may have been + // spent within the previous tip and the current one, and we may not poll this chunk of the + // chain anymore. + let to_be_spent: Vec = curr_coins + .values() + .chain(received.iter()) + .filter_map(|coin| { + if coin.spend_txid.is_none() { + Some(coin.outpoint) + } else { + None + } + }) + .collect(); + let spent = bit.spent_coins(&to_be_spent); + + UpdatedCoins { + received, + confirmed, + spent, + } +} + +// Returns the new block chain tip, if it changed. +fn new_tip(bit: &impl BitcoinInterface, current_tip: &BlockChainTip) -> Option { let bitcoin_tip = bit.chain_tip(); - let current_tip = match db_conn.chain_tip() { - Some(tip) => tip, - None => { - db_conn.update_tip(&bitcoin_tip); - return; - } - }; - // If the tip didn't change, there is nothing to update. - if current_tip == bitcoin_tip { - return; + if current_tip == &bitcoin_tip { + return None; } if bitcoin_tip.height > current_tip.height { // Make sure we are on the same chain. if bit.is_in_chain(¤t_tip) { - // All good, we just moved forward. Record the new tip. - db_conn.update_tip(&bitcoin_tip); - return; + // All good, we just moved forward. + return Some(bitcoin_tip); } } // TODO: reorg handling. + None +} + +fn updates(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) { + let mut db_conn = db.connection(); + + // Check if there was a new block before updating ourselves. + let current_tip = db_conn.chain_tip().expect("Always set at first startup"); + let new_tip = new_tip(bit, ¤t_tip); + let latest_tip = new_tip.unwrap_or(current_tip); + + // Then check the state of our coins. Do it even if the tip did not change since last poll, as + // we may have unconfirmed transactions. + let updated_coins = update_coins(bit, &mut db_conn, ¤t_tip); + + // If the tip changed while we were polling our Bitcoin interface, start over. + if bit.chain_tip() != latest_tip { + log::info!("Chain tip changed while we were updating our state. Starting over."); + return updates(bit, db); + } + + // The chain tip did not change since we started our updates. Record them and the latest tip. + // Having the tip in database means that, as far as the chain is concerned, we've got all + // updates up to this block. But not more. + db_conn.new_unspent_coins(&updated_coins.received); + db_conn.confirm_coins(&updated_coins.confirmed); + db_conn.spend_coins(&updated_coins.spent); + if let Some(tip) = new_tip { + db_conn.update_tip(&tip); + } +} + +// If the database chain tip is NULL (first startup), initialize it. +fn maybe_initialize_tip(bit: &impl BitcoinInterface, db: &impl DatabaseInterface) { + let mut db_conn = db.connection(); + + if db_conn.chain_tip().is_none() { + // TODO: be smarter. We can use the timestamp of the descriptor to get a newer block hash. + db_conn.update_tip(&bit.genesis_block()); + } } /// Main event loop. Repeatedly polls the Bitcoin interface until told to stop through the @@ -47,6 +163,8 @@ pub fn looper( let mut last_poll = None; let mut synced = false; + maybe_initialize_tip(&bit, &db); + while !shutdown.load(atomic::Ordering::Relaxed) || last_poll.is_none() { let now = time::Instant::now(); @@ -75,7 +193,6 @@ pub fn looper( } } - let mut db_conn = db.connection(); - update_tip(&bit, &mut db_conn); + updates(&bit, &db); } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f4c56fc8..6b1ef3f0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,7 +2,14 @@ //! //! External interface to the Minisafe daemon. -use crate::{bitcoin::BitcoinInterface, database::DatabaseInterface, DaemonControl, VERSION}; +mod utils; + +use crate::{ + bitcoin::BitcoinInterface, + database::{Coin, DatabaseInterface}, + DaemonControl, VERSION, +}; +use utils::{deser_amount_from_sats, ser_amount}; use miniscript::{ bitcoin, @@ -31,7 +38,7 @@ impl DaemonControl { let mut db_conn = self.db.connection(); let index = db_conn.derivation_index(); // TODO: handle should we wrap around instead of failing? - db_conn.update_derivation_index(index.increment().expect("TODO: handle wraparound")); + db_conn.increment_derivation_index(&self.secp); let address = self .config .main_descriptor @@ -43,6 +50,30 @@ impl DaemonControl { .expect("It's a wsh() descriptor"); GetAddressResult { address } } + + /// Get a list of all currently unspent coins. + pub fn list_coins(&self) -> ListCoinsResult { + let mut db_conn = self.db.connection(); + let coins: Vec = db_conn + .unspent_coins() + // Can't use into_values as of Rust 1.48 + .into_iter() + .map(|(_, coin)| { + let Coin { + amount, + outpoint, + block_height, + .. + } = coin; + ListCoinsEntry { + amount, + outpoint, + block_height, + } + }) + .collect(); + ListCoinsResult { coins } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -65,6 +96,22 @@ pub struct GetAddressResult { pub address: bitcoin::Address, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListCoinsEntry { + #[serde( + serialize_with = "ser_amount", + deserialize_with = "deser_amount_from_sats" + )] + pub amount: bitcoin::Amount, + pub outpoint: bitcoin::OutPoint, + pub block_height: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListCoinsResult { + pub coins: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/commands/utils.rs b/src/commands/utils.rs new file mode 100644 index 00000000..807aef6d --- /dev/null +++ b/src/commands/utils.rs @@ -0,0 +1,16 @@ +use miniscript::bitcoin; +use serde::{Deserialize, Deserializer, Serializer}; + +/// Serialize an amount as sats +pub fn ser_amount(amount: &bitcoin::Amount, s: S) -> Result { + s.serialize_u64(amount.as_sat()) +} + +/// Deserialize an amount from sats +pub fn deser_amount_from_sats<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let a = u64::deserialize(deserializer)?; + Ok(bitcoin::Amount::from_sat(a)) +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 6820661b..68f5d105 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -5,12 +5,15 @@ pub mod sqlite; use crate::{ bitcoin::BlockChainTip, - database::sqlite::{schema::DbTip, SqliteConn, SqliteDb}, + database::sqlite::{ + schema::{DbCoin, DbTip}, + SqliteConn, SqliteDb, + }, }; -use std::sync; +use std::{collections::HashMap, sync}; -use miniscript::bitcoin::util::bip32; +use miniscript::bitcoin::{self, secp256k1, util::bip32}; pub trait DatabaseInterface: Send { fn connection(&self) -> Box; @@ -33,12 +36,32 @@ pub trait DatabaseConnection { /// Get the tip of the best chain we've seen. fn chain_tip(&mut self) -> Option; + /// The network we are operating on. + fn network(&mut self) -> bitcoin::Network; + /// Update our best chain seen. fn update_tip(&mut self, tip: &BlockChainTip); fn derivation_index(&mut self) -> bip32::ChildNumber; - fn update_derivation_index(&mut self, index: bip32::ChildNumber); + fn increment_derivation_index(&mut self, secp: &secp256k1::Secp256k1); + + fn derivation_index_by_address( + &mut self, + address: &bitcoin::Address, + ) -> Option; + + /// Get all UTxOs. + fn unspent_coins(&mut self) -> HashMap; + + /// Store new UTxOs. Coins must not already be in database. + fn new_unspent_coins<'a>(&mut self, coins: &[Coin]); + + /// Mark a set of coins as being confirmed at a specified height. + fn confirm_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, i32)]); + + /// Mark a set of coins as being spent by a specified txid. + fn spend_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]); } impl DatabaseConnection for SqliteConn { @@ -53,6 +76,10 @@ impl DatabaseConnection for SqliteConn { } } + fn network(&mut self) -> bitcoin::Network { + self.db_tip().network + } + fn update_tip(&mut self, tip: &BlockChainTip) { self.update_tip(&tip) } @@ -61,7 +88,79 @@ impl DatabaseConnection for SqliteConn { self.db_wallet().deposit_derivation_index } - fn update_derivation_index(&mut self, index: bip32::ChildNumber) { - self.update_derivation_index(index) + fn increment_derivation_index(&mut self, secp: &secp256k1::Secp256k1) { + self.increment_derivation_index(secp) + } + + fn unspent_coins(&mut self) -> HashMap { + // FIXME: if possible, avoid reallocating. + self.unspent_coins() + .into_iter() + .map(|db_coin| { + let DbCoin { + outpoint, + block_height, + amount, + derivation_index, + spend_txid, + .. + } = db_coin; + ( + outpoint, + Coin { + outpoint, + block_height, + amount, + derivation_index, + spend_txid, + }, + ) + }) + .collect() + } + + fn new_unspent_coins<'a>(&mut self, coins: &[Coin]) { + self.new_unspent_coins(coins) + } + + fn confirm_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, i32)]) { + self.confirm_coins(outpoints) + } + + fn spend_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]) { + self.spend_coins(outpoints) + } + + fn derivation_index_by_address( + &mut self, + address: &bitcoin::Address, + ) -> Option { + self.db_address(address) + .map(|db_addr| db_addr.derivation_index) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Coin { + pub outpoint: bitcoin::OutPoint, + pub block_height: Option, + pub amount: bitcoin::Amount, + pub derivation_index: bip32::ChildNumber, + pub spend_txid: Option, +} + +impl std::hash::Hash for Coin { + fn hash(&self, h: &mut H) { + self.outpoint.hash(h) + } +} + +impl Coin { + pub fn is_confirmed(&self) -> bool { + self.block_height.is_some() + } + + pub fn is_spent(&self) -> bool { + self.spend_txid.is_some() } } diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index 2e81b54b..91315ba5 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -11,17 +11,20 @@ mod utils; use crate::{ bitcoin::BlockChainTip, - database::sqlite::{ - schema::{DbTip, DbWallet}, - utils::{create_fresh_db, db_exec, db_query}, + database::{ + sqlite::{ + schema::{DbAddress, DbCoin, DbTip, DbWallet}, + utils::{create_fresh_db, db_exec, db_query, db_tx_query, LOOK_AHEAD_LIMIT}, + }, + Coin, }, }; use std::{convert::TryInto, fmt, io, path}; use miniscript::{ - bitcoin::{self, util::bip32}, - Descriptor, DescriptorPublicKey, + bitcoin::{self, secp256k1}, + Descriptor, DescriptorPublicKey, DescriptorTrait, TranslatePk2, }; const DB_VERSION: i64 = 0; @@ -90,10 +93,11 @@ impl SqliteDb { pub fn new( db_path: path::PathBuf, fresh_options: Option, + secp: &secp256k1::Secp256k1, ) -> Result { // Create the database if needed, and make sure the db file exists. if let Some(options) = fresh_options { - create_fresh_db(&db_path, options)?; + create_fresh_db(&db_path, options, secp)?; log::info!("Created a fresh database at {}.", db_path.display()); } if !db_path.exists() { @@ -141,6 +145,9 @@ impl SqliteDb { } } +// We only support single wallet. The id of the wallet row is always 1. +const WALLET_ID: i64 = 1; + pub struct SqliteConn { conn: rusqlite::Connection, } @@ -200,30 +207,147 @@ impl SqliteConn { .expect("Database must be available") } - /// Update the deposit derivation index. - pub fn update_derivation_index(&mut self, index: bip32::ChildNumber) { - let new_index: u32 = index.into(); + pub fn increment_derivation_index( + &mut self, + secp: &secp256k1::Secp256k1, + ) { + let network = self.db_tip().network; + db_exec(&mut self.conn, |db_tx| { + let db_wallet: DbWallet = db_tx_query( + &db_tx, + "SELECT * FROM wallets", + rusqlite::params![], + |row| row.try_into(), + ) + .expect("Db must not fail") + .pop() + .expect("There is always a row in the wallet table"); + let next_index: u32 = db_wallet + .deposit_derivation_index + .increment() + .expect("Must not get in hardened territory") + .into(); // NOTE: should be updated if we ever have multi-wallet support + db_tx.execute( + "UPDATE wallets SET deposit_derivation_index = (?1)", + rusqlite::params![next_index], + )?; + + // Update the address to derivation index mapping. + // TODO: have this as a helper in descriptors.rs + let next_la_index = next_index + LOOK_AHEAD_LIMIT - 1; + let next_la_address = db_wallet + .main_descriptor + .derive(next_la_index) + .translate_pk2(|xpk| xpk.derive_public_key(secp)) + .expect("All pubkeys were derived, no wildcard.") + .address(network) + .expect("It's a wsh() descriptor"); db_tx .execute( - "UPDATE wallets SET deposit_derivation_index = (?1)", - rusqlite::params![new_index], + "INSERT INTO addresses (address, derivation_index) VALUES (?1, ?2)", + rusqlite::params![next_la_address.to_string(), next_la_index], ) .map(|_| ()) }) .expect("Database must be available") } + + /// Get all UTxOs. + pub fn unspent_coins(&mut self) -> Vec { + db_query( + &mut self.conn, + "SELECT * FROM coins WHERE spend_txid is NULL", + rusqlite::params![], + |row| row.try_into(), + ) + .expect("Db must not fail") + } + + // FIXME: don't take the whole coin, we don't need it. + /// Store new, unconfirmed and unspent, coins. + /// Will panic if given a coin that is already in DB. + pub fn new_unspent_coins<'a>(&mut self, coins: impl IntoIterator) { + db_exec(&mut self.conn, |db_tx| { + for coin in coins { + let deriv_index: u32 = coin.derivation_index.into(); + db_tx.execute( + "INSERT INTO coins (wallet_id, txid, vout, amount_sat, derivation_index) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + WALLET_ID, + coin.outpoint.txid.to_vec(), + coin.outpoint.vout, + coin.amount.as_sat(), + deriv_index, + ], + )?; + } + Ok(()) + }) + .expect("Database must be available") + } + + /// Mark a set of coins as confirmed. + pub fn confirm_coins<'a>( + &mut self, + outpoints: impl IntoIterator, + ) { + db_exec(&mut self.conn, |db_tx| { + for (outpoint, height) in outpoints { + db_tx.execute( + "UPDATE coins SET blockheight = ?1 WHERE txid = ?2 AND vout = ?3", + rusqlite::params![height, outpoint.txid.to_vec(), outpoint.vout,], + )?; + } + + Ok(()) + }) + .expect("Database must be available") + } + + /// Mark a set of coins as spent. + pub fn spend_coins<'a>( + &mut self, + outpoints: impl IntoIterator, + ) { + db_exec(&mut self.conn, |db_tx| { + for (outpoint, spend_txid) in outpoints { + db_tx.execute( + "UPDATE coins SET spend_txid = ?1 WHERE txid = ?2 AND vout = ?3", + rusqlite::params![spend_txid.to_vec(), outpoint.txid.to_vec(), outpoint.vout,], + )?; + } + + Ok(()) + }) + .expect("Database must be available") + } + + pub fn db_address(&mut self, address: &bitcoin::Address) -> Option { + db_query( + &mut self.conn, + "SELECT * FROM addresses WHERE address = ?1", + rusqlite::params![address.to_string()], + |row| row.try_into(), + ) + .expect("Db must not fail") + .pop() + } } #[cfg(test)] mod tests { use super::*; use crate::testutils::*; - use std::{fs, path, str::FromStr}; + use std::{collections::HashSet, fs, path, str::FromStr}; + + use bitcoin::{hashes::Hash, util::bip32}; + use miniscript::{DescriptorTrait, TranslatePk2}; fn dummy_options() -> FreshDbOptions { - let desc_str = "wsh(andor(pk(03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))"; + let desc_str = "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))#y5wcna2d"; let main_descriptor = Descriptor::::from_str(desc_str).unwrap(); FreshDbOptions { bitcoind_network: bitcoin::Network::Bitcoin, @@ -231,22 +355,42 @@ mod tests { } } - #[test] - fn db_startup_sanity_checks() { + fn dummy_db() -> ( + path::PathBuf, + FreshDbOptions, + secp256k1::Secp256k1, + SqliteDb, + ) { let tmp_dir = tmp_dir(); fs::create_dir_all(&tmp_dir).unwrap(); + let secp = secp256k1::Secp256k1::verification_only(); let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")] .iter() .collect(); - assert!(SqliteDb::new(db_path.clone(), None) + let options = dummy_options(); + let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap(); + + (tmp_dir, options, secp, db) + } + + #[test] + fn db_startup_sanity_checks() { + let tmp_dir = tmp_dir(); + fs::create_dir_all(&tmp_dir).unwrap(); + let secp = secp256k1::Secp256k1::verification_only(); + + let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")] + .iter() + .collect(); + assert!(SqliteDb::new(db_path.clone(), None, &secp) .unwrap_err() .to_string() .contains("database file not found")); let options = dummy_options(); - let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap(); + let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap(); db.sanity_check(bitcoin::Network::Testnet, &options.main_descriptor) .unwrap_err() .to_string() @@ -254,7 +398,7 @@ mod tests { fs::remove_file(&db_path).unwrap(); let other_desc_str = "wsh(andor(pk(037a27a76ebf33594c785e4fa41607860a960bb5aa3039654297b05bff57e4f9a9),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))"; let other_desc = Descriptor::::from_str(other_desc_str).unwrap(); - let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap(); + let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap(); db.sanity_check(bitcoin::Network::Bitcoin, &other_desc) .unwrap_err() .to_string() @@ -262,10 +406,10 @@ mod tests { fs::remove_file(&db_path).unwrap(); // TODO: version check - let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap(); + let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap(); db.sanity_check(bitcoin::Network::Bitcoin, &options.main_descriptor) .unwrap(); - let db = SqliteDb::new(db_path.clone(), None).unwrap(); + let db = SqliteDb::new(db_path.clone(), None, &secp).unwrap(); db.sanity_check(bitcoin::Network::Bitcoin, &options.main_descriptor) .unwrap(); @@ -274,14 +418,7 @@ mod tests { #[test] fn db_tip_update() { - let tmp_dir = tmp_dir(); - fs::create_dir_all(&tmp_dir).unwrap(); - - let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")] - .iter() - .collect(); - let options = dummy_options(); - let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap(); + let (tmp_dir, options, _, db) = dummy_db(); { let mut conn = db.connection().unwrap(); @@ -306,4 +443,120 @@ mod tests { fs::remove_dir_all(&tmp_dir).unwrap(); } + + #[test] + fn db_coins_update() { + let (tmp_dir, _, _, db) = dummy_db(); + + { + let mut conn = db.connection().unwrap(); + + // Necessarily empty at first. + assert!(conn.unspent_coins().is_empty()); + + // Add one, we'll get it. + let coin_a = Coin { + outpoint: bitcoin::OutPoint::from_str( + "6f0dc85a369b44458eba3a1f0ea5b5935d563afb6994f70f5b0094e05be1676c:1", + ) + .unwrap(), + block_height: None, + amount: bitcoin::Amount::from_sat(98765), + derivation_index: bip32::ChildNumber::from_normal_idx(10).unwrap(), + spend_txid: None, + }; + conn.new_unspent_coins(&[coin_a.clone()]); // On 1.48, arrays aren't IntoIterator + assert_eq!(conn.unspent_coins()[0].outpoint, coin_a.outpoint); + + // Add a second one, we'll get both. + let coin_b = Coin { + outpoint: bitcoin::OutPoint::from_str( + "61db3e276b095e5b05f1849dd6bfffb4e7e5ec1c4a4210099b98fce01571936f:12", + ) + .unwrap(), + block_height: None, + amount: bitcoin::Amount::from_sat(1111), + derivation_index: bip32::ChildNumber::from_normal_idx(103).unwrap(), + spend_txid: None, + }; + conn.new_unspent_coins(&[coin_b.clone()]); + let outpoints: HashSet = conn + .unspent_coins() + .into_iter() + .map(|c| c.outpoint) + .collect(); + assert!(outpoints.contains(&coin_a.outpoint)); + assert!(outpoints.contains(&coin_b.outpoint)); + + // Now if we confirm one, it'll be marked as such. + let height = 174500; + conn.confirm_coins(&[(coin_a.outpoint, height)]); + let coins = conn.unspent_coins(); + assert_eq!(coins[0].block_height, Some(height)); + assert!(coins[1].block_height.is_none()); + + // Now if we spend one, we'll only get the other one. + conn.spend_coins(&[( + coin_a.outpoint, + bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(), + )]); + let outpoints: HashSet = conn + .unspent_coins() + .into_iter() + .map(|c| c.outpoint) + .collect(); + assert!(!outpoints.contains(&coin_a.outpoint)); + assert!(outpoints.contains(&coin_b.outpoint)); + } + + fs::remove_dir_all(&tmp_dir).unwrap(); + } + + #[test] + fn sqlite_addresses_cache() { + let (tmp_dir, options, secp, db) = dummy_db(); + + { + let mut conn = db.connection().unwrap(); + + // There is the index for the first index + let addr = options + .main_descriptor + .derive(0) + .translate_pk2(|xpk| xpk.derive_public_key(&secp)) + .expect("All pubkeys were derived, no wildcard.") + .address(options.bitcoind_network) + .expect("Always a P2WSH address"); + let db_addr = conn.db_address(&addr).unwrap(); + assert_eq!(db_addr.derivation_index, 0.into()); + + // There is the index for the 199th index (look-ahead limit) + let addr = options + .main_descriptor + .derive(199) + .translate_pk2(|xpk| xpk.derive_public_key(&secp)) + .expect("All pubkeys were derived, no wildcard.") + .address(options.bitcoind_network) + .expect("Always a P2WSH address"); + let db_addr = conn.db_address(&addr).unwrap(); + assert_eq!(db_addr.derivation_index, 199.into()); + + // And not for the 200th one. + let addr = options + .main_descriptor + .derive(200) + .translate_pk2(|xpk| xpk.derive_public_key(&secp)) + .expect("All pubkeys were derived, no wildcard.") + .address(options.bitcoind_network) + .expect("Always a P2WSH address"); + assert!(conn.db_address(&addr).is_none()); + + // But if we increment the deposit derivation index, the 200th one will be there. + conn.increment_derivation_index(&secp); + let db_addr = conn.db_address(&addr).unwrap(); + assert_eq!(db_addr.derivation_index, 200.into()); + } + + fs::remove_dir_all(&tmp_dir).unwrap(); + } } diff --git a/src/database/sqlite/schema.rs b/src/database/sqlite/schema.rs index d58fb06b..1a0718aa 100644 --- a/src/database/sqlite/schema.rs +++ b/src/database/sqlite/schema.rs @@ -26,6 +26,30 @@ CREATE TABLE wallets ( main_descriptor TEXT NOT NULL, deposit_derivation_index INTEGER NOT NULL ); + +/* Our (U)TxOs. */ +CREATE TABLE coins ( + id INTEGER PRIMARY KEY NOT NULL, + wallet_id INTEGER NOT NULL, + blockheight INTEGER, + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + amount_sat INTEGER NOT NULL, + derivation_index INTEGER NOT NULL, + spend_txid BLOB, + UNIQUE (txid, vout), + FOREIGN KEY (wallet_id) REFERENCES wallets (id) + ON UPDATE RESTRICT + ON DELETE RESTRICT +); + +/* A mapping from descriptor address to derivation index. Necessary until + * we can get the derivation index from the parent descriptor from bitcoind. + */ +CREATE TABLE addresses ( + address TEXT NOT NULL UNIQUE, + derivation_index INTEGER NOT NULL UNIQUE +); "; /// A row in the "tip" table. @@ -88,3 +112,78 @@ impl TryFrom<&rusqlite::Row<'_>> for DbWallet { }) } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbCoin { + pub id: i64, + pub wallet_id: i64, + pub outpoint: bitcoin::OutPoint, + pub block_height: Option, + pub amount: bitcoin::Amount, + pub derivation_index: bip32::ChildNumber, + pub spend_txid: Option, +} + +impl std::hash::Hash for DbCoin { + fn hash(&self, h: &mut H) { + self.outpoint.hash(h) + } +} + +impl TryFrom<&rusqlite::Row<'_>> for DbCoin { + type Error = rusqlite::Error; + + fn try_from(row: &rusqlite::Row) -> Result { + let id = row.get(0)?; + let wallet_id = row.get(1)?; + + let block_height = row.get(2)?; + let txid: Vec = row.get(3)?; + let txid: bitcoin::Txid = encode::deserialize(&txid).expect("We only store valid txids"); + let vout = row.get(4)?; + let outpoint = bitcoin::OutPoint { txid, vout }; + + let amount = row.get(5)?; + let amount = bitcoin::Amount::from_sat(amount); + let der_idx: u32 = row.get(6)?; + let derivation_index = bip32::ChildNumber::from(der_idx); + + let spend_txid: Option> = row.get(7)?; + let spend_txid = + spend_txid.map(|txid| encode::deserialize(&txid).expect("We only store valid txids")); + + Ok(DbCoin { + id, + wallet_id, + outpoint, + block_height, + amount, + derivation_index, + spend_txid, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbAddress { + pub address: bitcoin::Address, + pub derivation_index: bip32::ChildNumber, +} + +impl TryFrom<&rusqlite::Row<'_>> for DbAddress { + type Error = rusqlite::Error; + + fn try_from(row: &rusqlite::Row) -> Result { + let address: String = row.get(0)?; + let address = bitcoin::Address::from_str(&address).expect("We only store valid addresses"); + + let derivation_index: u32 = row.get(1)?; + let derivation_index = bip32::ChildNumber::from(derivation_index); + assert!(derivation_index.is_normal()); + + Ok(DbAddress { + address, + derivation_index, + }) + } +} diff --git a/src/database/sqlite/utils.rs b/src/database/sqlite/utils.rs index 40b4f3f7..e744ce4b 100644 --- a/src/database/sqlite/utils.rs +++ b/src/database/sqlite/utils.rs @@ -2,6 +2,10 @@ use crate::database::sqlite::{schema::SCHEMA, FreshDbOptions, SqliteDbError, DB_ use std::{convert::TryInto, fs, path, time}; +use miniscript::{bitcoin::secp256k1, DescriptorTrait, TranslatePk2}; + +pub const LOOK_AHEAD_LIMIT: u32 = 200; + /// Perform a set of modifications to the database inside a single transaction pub fn db_exec(conn: &mut rusqlite::Connection, modifications: F) -> Result<(), rusqlite::Error> where @@ -12,6 +16,27 @@ where tx.commit() } +/// Internal helper for queries boilerplate +pub fn db_tx_query( + tx: &rusqlite::Transaction, + stmt_str: &str, + params: P, + f: F, +) -> Result, rusqlite::Error> +where + P: IntoIterator + rusqlite::Params, + P::Item: rusqlite::ToSql, + F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + // rustc says 'borrowed value does not live long enough' + let x = tx + .prepare(stmt_str)? + .query_map(params, f)? + .collect::>>(); + + x +} + /// Internal helper for queries boilerplate pub fn db_query( conn: &mut rusqlite::Connection, @@ -62,7 +87,11 @@ pub fn create_db_file(db_path: &path::Path) -> Result<(), std::io::Error> { }; } -pub fn create_fresh_db(db_path: &path::Path, options: FreshDbOptions) -> Result<(), SqliteDbError> { +pub fn create_fresh_db( + db_path: &path::Path, + options: FreshDbOptions, + secp: &secp256k1::Secp256k1, +) -> Result<(), SqliteDbError> { create_db_file(db_path)?; let timestamp = time::SystemTime::now() @@ -70,6 +99,24 @@ pub fn create_fresh_db(db_path: &path::Path, options: FreshDbOptions) -> Result< .map(|dur| timestamp_to_u32(dur.as_secs())) .expect("System clock went backward the epoch?"); + // Fill the initial addresses. On a fresh database, the deposit_derivation_index is + // necessarily 0. + let mut query = String::with_capacity(100 * LOOK_AHEAD_LIMIT as usize); + for index in 0..LOOK_AHEAD_LIMIT { + // TODO: have this as a helper in descriptors.rs + let address = options + .main_descriptor + .derive(index) + .translate_pk2(|xpk| xpk.derive_public_key(secp)) + .expect("All pubkeys were derived, no wildcard.") + .address(options.bitcoind_network) + .expect("Always a P2WSH address"); + query += &format!( + "INSERT INTO addresses (address, derivation_index) VALUES (\"{}\", {});\n", + address, index + ); + } + let mut conn = rusqlite::Connection::open(db_path)?; db_exec(&mut conn, |tx| { tx.execute_batch(SCHEMA)?; @@ -86,6 +133,7 @@ pub fn create_fresh_db(db_path: &path::Path, options: FreshDbOptions) -> Result< VALUES (?1, ?2, ?3)", rusqlite::params![timestamp, options.main_descriptor.to_string(), 0,], )?; + tx.execute_batch(&query)?; Ok(()) })?; diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index 82fe1a42..b748063a 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -8,6 +8,7 @@ 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()), "stop" => serde_json::json!({}), _ => { return Err(Error::method_not_found()); diff --git a/src/lib.rs b/src/lib.rs index 80f0f71c..5d9ef901 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -161,6 +161,7 @@ fn setup_sqlite( config: &Config, data_dir: &path::Path, fresh_data_dir: bool, + secp: &secp256k1::Secp256k1, ) -> Result { let db_path: path::PathBuf = [data_dir, path::Path::new("minisafed.sqlite3")] .iter() @@ -173,7 +174,7 @@ fn setup_sqlite( } else { None }; - let sqlite = SqliteDb::new(db_path, options)?; + let sqlite = SqliteDb::new(db_path, options, secp)?; sqlite.sanity_check(config.bitcoin_config.network, &config.main_descriptor)?; log::info!("Database initialized and checked."); @@ -223,8 +224,8 @@ impl DaemonControl { config: Config, bitcoin: sync::Arc>, db: sync::Arc>, + secp: secp256k1::Secp256k1, ) -> DaemonControl { - let secp = secp256k1::Secp256k1::verification_only(); DaemonControl { config, bitcoin, @@ -257,6 +258,8 @@ impl DaemonHandle { #[cfg(not(test))] setup_panic_hook(); + let secp = secp256k1::Secp256k1::verification_only(); + // First, check the data directory let mut data_dir = config .data_dir() @@ -275,6 +278,7 @@ impl DaemonHandle { &config, &data_dir, fresh_data_dir, + &secp, )?)) as sync::Arc>, }; @@ -310,7 +314,7 @@ impl DaemonHandle { ); // Finally, set up the API. - let control = DaemonControl::new(config, bit, db); + let control = DaemonControl::new(config, bit, db, secp); Ok(Self { control, @@ -513,6 +517,18 @@ mod tests { stream.flush().unwrap(); } + // Send them a response to 'getblockhash' with the genesis block hash + fn complete_tip_init<'a>(server: &net::TcpListener) { + let net_resp = [ + "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f\"}\n".as_bytes(), + ] + .concat(); + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(&net_resp).unwrap(); + stream.flush().unwrap(); + } + // Send them a response to 'getblockchaininfo' saying we are far from being synced fn complete_sync_check<'a>(server: &net::TcpListener) { let net_resp = [ @@ -598,6 +614,7 @@ mod tests { complete_network_check(&server); complete_wallet_check(&server, &wo_path); complete_desc_check(&server, desc_str); + complete_tip_init(&server); complete_sync_check(&server); daemon_thread.join().unwrap(); diff --git a/src/testutils.rs b/src/testutils.rs index f38d73b3..9f4781ba 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -1,20 +1,28 @@ use crate::{ - bitcoin::{BitcoinInterface, BlockChainTip}, + bitcoin::{BitcoinInterface, BlockChainTip, UTxO}, config::{BitcoinConfig, Config}, - database::{DatabaseConnection, DatabaseInterface}, + database::{Coin, DatabaseConnection, DatabaseInterface}, DaemonHandle, }; -use std::{env, fs, io, path, process, str::FromStr, sync, thread, time}; +use std::{collections::HashMap, env, fs, io, path, process, str::FromStr, sync, thread, time}; use miniscript::{ - bitcoin::{self, util::bip32}, + bitcoin::{self, secp256k1, util::bip32}, descriptor, }; 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,11 +40,24 @@ 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 { curr_index: bip32::ChildNumber, curr_tip: Option, + coins: HashMap, } impl DummyDb { @@ -44,6 +65,7 @@ impl DummyDb { DummyDb { curr_index: 0.into(), curr_tip: None, + coins: HashMap::new(), } } } @@ -59,6 +81,10 @@ pub struct DummyDbConn { } impl DatabaseConnection for DummyDbConn { + fn network(&mut self) -> bitcoin::Network { + bitcoin::Network::Bitcoin + } + fn chain_tip(&mut self) -> Option { self.db.read().unwrap().curr_tip } @@ -71,8 +97,45 @@ impl DatabaseConnection for DummyDbConn { self.db.read().unwrap().curr_index } - fn update_derivation_index(&mut self, index: bip32::ChildNumber) { - self.db.write().unwrap().curr_index = index; + fn increment_derivation_index(&mut self, _: &secp256k1::Secp256k1) { + let next_index = self.db.write().unwrap().curr_index.increment().unwrap(); + self.db.write().unwrap().curr_index = next_index; + } + + fn unspent_coins(&mut self) -> HashMap { + self.db.read().unwrap().coins.clone() + } + + fn new_unspent_coins<'a>(&mut self, coins: &[Coin]) { + for coin in coins { + self.db + .write() + .unwrap() + .coins + .insert(coin.outpoint, coin.clone()); + } + } + + fn confirm_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, i32)]) { + for (op, height) in outpoints { + let mut db = self.db.write().unwrap(); + let h = &mut db.coins.get_mut(op).unwrap().block_height; + assert!(h.is_none()); + *h = Some(*height); + } + } + + fn spend_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]) { + for (op, spend_txid) in outpoints { + let mut db = self.db.write().unwrap(); + let spender = &mut db.coins.get_mut(op).unwrap().spend_txid; + assert!(spender.is_none()); + *spender = Some(*spend_txid); + } + } + + fn derivation_index_by_address(&mut self, _: &bitcoin::Address) -> Option { + None } } diff --git a/tests/test_rpc.py b/tests/test_rpc.py index de67b85f..97ade6c1 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -1,4 +1,5 @@ from fixtures import * +from test_framework.utils import wait_for, COIN def test_getinfo(minisafed): @@ -15,3 +16,27 @@ def test_getaddress(minisafed): assert "address" in res # We'll get a new one at every call assert res["address"] != minisafed.rpc.getnewaddress()["address"] + + +def test_listcoins(minisafed, bitcoind): + # Initially empty + res = minisafed.rpc.listcoins() + assert "coins" in res + assert len(res["coins"]) == 0 + + # If we send a coin, we'll get a new entry. Note we monitor for unconfirmed + # funds as well. + addr = minisafed.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, 1) + wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) == 1) + res = minisafed.rpc.listcoins()["coins"] + assert txid == res[0]["outpoint"][:64] + assert res[0]["amount"] == 1 * COIN + assert res[0]["block_height"] is None + + # 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: minisafed.rpc.listcoins()["coins"][0]["block_height"] == block_height + )