diff --git a/signer/src/bitcoin/client.rs b/signer/src/bitcoin/client.rs index f93cea40..160e6a78 100644 --- a/signer/src/bitcoin/client.rs +++ b/signer/src/bitcoin/client.rs @@ -19,12 +19,9 @@ use bitcoin::Txid; use bitcoincore_rpc::RpcApi as _; use url::Url; -use crate::bitcoin::utxo; -use crate::bitcoin::utxo::SignerUtxo; -use crate::bitcoin::BitcoinInteract; -use crate::error::Error; -use crate::keys::PublicKey; -use crate::util::ApiFallbackClient; +use crate::{error::Error, util::ApiFallbackClient}; + +use super::{utxo, BitcoinInteract}; use super::rpc::BitcoinCoreClient; use super::rpc::BitcoinTxInfo; @@ -69,13 +66,6 @@ impl BitcoinInteract for ApiFallbackClient { todo!() // TODO(542) } - async fn get_signer_utxo( - &self, - _aggregate_key: &PublicKey, - ) -> Result, Error> { - todo!() // TODO(538) - } - async fn get_last_fee(&self, _utxo: bitcoin::OutPoint) -> Result, Error> { todo!() // TODO(541) } diff --git a/signer/src/bitcoin/mod.rs b/signer/src/bitcoin/mod.rs index c5908c0f..8f4dadc9 100644 --- a/signer/src/bitcoin/mod.rs +++ b/signer/src/bitcoin/mod.rs @@ -9,7 +9,6 @@ use rpc::BitcoinTxInfo; use rpc::GetTxResponse; use crate::error::Error; -use crate::keys::PublicKey; pub mod client; pub mod fees; @@ -41,12 +40,6 @@ pub trait BitcoinInteract: Sync + Send { // This should be implemented with the help of the `fees::EstimateFees` trait fn estimate_fee_rate(&self) -> impl std::future::Future> + Send; - /// Get the outstanding signer UTXO - fn get_signer_utxo( - &self, - aggregate_key: &PublicKey, - ) -> impl Future, Error>> + Send; - /// Get the total fee amount and the fee rate for the last transaction that /// used the given UTXO as an input. fn get_last_fee( diff --git a/signer/src/bitcoin/rpc.rs b/signer/src/bitcoin/rpc.rs index 20e83f78..3a9259b3 100644 --- a/signer/src/bitcoin/rpc.rs +++ b/signer/src/bitcoin/rpc.rs @@ -24,7 +24,6 @@ use url::Url; use crate::bitcoin::BitcoinInteract; use crate::error::Error; -use crate::keys::PublicKey; /// A slimmed down type representing a response from bitcoin-core's /// getrawtransaction RPC. @@ -344,13 +343,6 @@ impl BitcoinInteract for BitcoinCoreClient { todo!() } - async fn get_signer_utxo( - &self, - _: &PublicKey, - ) -> Result, Error> { - todo!() - } - async fn get_last_fee(&self, _: OutPoint) -> Result, Error> { todo!() } diff --git a/signer/src/bitcoin/utxo.rs b/signer/src/bitcoin/utxo.rs index c6b70c0f..93fda8c0 100644 --- a/signer/src/bitcoin/utxo.rs +++ b/signer/src/bitcoin/utxo.rs @@ -539,7 +539,7 @@ impl<'a> Requests<'a> { /// taproot. This is necessary because the signers collectively generate /// Schnorr signatures, which requires taproot. /// * The taproot script for each signer UTXO is a key-spend only script. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct SignerUtxo { /// The outpoint of the signers' UTXO pub outpoint: OutPoint, diff --git a/signer/src/block_observer.rs b/signer/src/block_observer.rs index f5fb62e8..420e8f1d 100644 --- a/signer/src/block_observer.rs +++ b/signer/src/block_observer.rs @@ -278,7 +278,7 @@ where let sbtc_txs = txs .iter() .filter(|tx| { - // If any of the outputs are spend to one of the signers' + // If any of the outputs are spent to one of the signers' // addresses, then we care about it tx.output .iter() @@ -828,12 +828,6 @@ mod tests { unimplemented!() } - async fn get_signer_utxo( - &self, - _point: &PublicKey, - ) -> Result, Error> { - unimplemented!() - } async fn get_last_fee( &self, _utxo: bitcoin::OutPoint, diff --git a/signer/src/error.rs b/signer/src/error.rs index dd5afdca..ac7f3357 100644 --- a/signer/src/error.rs +++ b/signer/src/error.rs @@ -309,6 +309,10 @@ pub enum Error { #[error("missing signer utxo")] MissingSignerUtxo, + /// Too many signer utxo + #[error("too many signer utxo")] + TooManySignerUtxos, + /// Invalid signature #[error("invalid signature")] InvalidSignature, diff --git a/signer/src/storage/in_memory.rs b/signer/src/storage/in_memory.rs index 9b92ee8b..8e0b2667 100644 --- a/signer/src/storage/in_memory.rs +++ b/signer/src/storage/in_memory.rs @@ -1,15 +1,20 @@ //! In-memory store implementation - useful for tests +use bitcoin::consensus::Decodable; use bitcoin::OutPoint; use blockstack_lib::types::chainstate::StacksBlockId; use futures::StreamExt; use futures::TryStreamExt; +use secp256k1::XOnlyPublicKey; use std::collections::HashMap; +use std::collections::HashSet; use std::sync::Arc; use tokio::sync::Mutex; +use crate::bitcoin::utxo::SignerUtxo; use crate::error::Error; use crate::keys::PublicKey; +use crate::keys::SignerScriptPubKey as _; use crate::stacks::events::CompletedDepositEvent; use crate::stacks::events::WithdrawalAcceptEvent; use crate::stacks::events::WithdrawalCreateEvent; @@ -46,6 +51,9 @@ pub struct Store { /// Withdraw signers pub withdrawal_request_to_signers: HashMap>, + /// Raw transaction data + pub raw_transactions: HashMap<[u8; 32], model::Transaction>, + /// Bitcoin blocks to transactions pub bitcoin_block_to_transactions: HashMap>, @@ -414,6 +422,80 @@ impl super::DbRead for SharedStore { .collect()) } + async fn get_signer_utxo( + &self, + chain_tip: &model::BitcoinBlockHash, + aggregate_key: &PublicKey, + ) -> Result, Error> { + let script_pubkey = aggregate_key.signers_script_pubkey(); + let store = self.lock().await; + let bitcoin_blocks = &store.bitcoin_blocks; + let first = bitcoin_blocks.get(chain_tip); + + // Traverse the canonical chain backwards and find the first block containing relevant sbtc tx(s) + let sbtc_txs = std::iter::successors(first, |block| bitcoin_blocks.get(&block.parent_hash)) + .filter_map(|block| { + let txs = store.bitcoin_block_to_transactions.get(&block.block_hash)?; + + let mut sbtc_txs = txs + .iter() + .filter_map(|tx| store.raw_transactions.get(&tx.into_bytes())) + .filter(|sbtc_tx| sbtc_tx.tx_type == model::TransactionType::SbtcTransaction) + .filter_map(|tx| { + bitcoin::Transaction::consensus_decode(&mut tx.tx.as_slice()).ok() + }) + .filter(|tx| { + tx.output + .first() + .is_some_and(|out| out.script_pubkey == script_pubkey) + }) + .peekable(); + + if sbtc_txs.peek().is_some() { + Some(sbtc_txs.collect::>()) + } else { + None + } + }) + .next(); + + // `sbtc_txs` contains all the txs in the highest canonical block where the first + // output is spendable by script_pubkey + let Some(sbtc_txs) = sbtc_txs else { + return Ok(None); + }; + + let spent: HashSet = sbtc_txs + .iter() + .flat_map(|tx| tx.input.iter().map(|txin| txin.previous_output)) + .collect(); + + let utxos = sbtc_txs + .iter() + .flat_map(|tx| { + if let Some(tx_out) = tx.output.first() { + let outpoint = OutPoint::new(tx.compute_txid(), 0); + if !spent.contains(&outpoint) { + return Some(SignerUtxo { + outpoint, + amount: tx_out.value.to_sat(), + // Txs were filtered based on the `aggregate_key` script pubkey + public_key: XOnlyPublicKey::from(aggregate_key), + }); + } + } + + None + }) + .collect::>(); + + match utxos[..] { + [] => Ok(None), + [utxo] => Ok(Some(utxo)), + _ => Err(Error::TooManySignerUtxos), + } + } + async fn get_deposit_request_signer_votes( &self, txid: &model::BitcoinTxId, @@ -636,8 +718,12 @@ impl super::DbWrite for SharedStore { Ok(()) } - async fn write_transaction(&self, _transaction: &model::Transaction) -> Result<(), Error> { - // Currently not needed in-memory since it's not required by any queries + async fn write_transaction(&self, transaction: &model::Transaction) -> Result<(), Error> { + self.lock() + .await + .raw_transactions + .insert(transaction.txid, transaction.clone()); + Ok(()) } diff --git a/signer/src/storage/mod.rs b/signer/src/storage/mod.rs index 46ae65d2..cb67edc2 100644 --- a/signer/src/storage/mod.rs +++ b/signer/src/storage/mod.rs @@ -15,6 +15,7 @@ use std::future::Future; use blockstack_lib::types::chainstate::StacksBlockId; +use crate::bitcoin::utxo::SignerUtxo; use crate::error::Error; use crate::keys::PublicKey; use crate::stacks::events::CompletedDepositEvent; @@ -130,6 +131,24 @@ pub trait DbRead { &self, ) -> impl Future, Error>> + Send; + /// Get the outstanding signer UTXO. + /// + /// Under normal conditions, the signer will have only one UTXO they can spend. + /// The specific UTXO we want is one such that: + /// 1. The transaction is in a block on the canonical bitcoin blockchain. + /// 2. The output is the first output in the transaction. + /// 3. The output's `scriptPubKey` matches `aggregate_key`. + /// 4. The output is unspent. It is possible for more than one transaction + /// within the same block to satisfy points 1-3, but if the signers + /// have one or more transactions within a block, exactly one output + /// satisfying points 1-3 will be unspent. + /// 5. The block that includes the transaction that satisfies points 1-4 has the greatest height of all such blocks. + fn get_signer_utxo( + &self, + chain_tip: &model::BitcoinBlockHash, + aggregate_key: &crate::keys::PublicKey, + ) -> impl Future, Error>> + Send; + /// For the given outpoint and aggregate key, get the list all signer /// votes in the signer set. fn get_deposit_request_signer_votes( diff --git a/signer/src/storage/model.rs b/signer/src/storage/model.rs index c0960696..bf803243 100644 --- a/signer/src/storage/model.rs +++ b/signer/src/storage/model.rs @@ -46,7 +46,7 @@ pub struct StacksBlock { } /// Deposit request. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, sqlx::FromRow)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, sqlx::FromRow)] #[cfg_attr(feature = "testing", derive(fake::Dummy))] pub struct DepositRequest { /// Transaction ID of the deposit request transaction. diff --git a/signer/src/storage/postgres.rs b/signer/src/storage/postgres.rs index 8dd25943..2aa3368d 100644 --- a/signer/src/storage/postgres.rs +++ b/signer/src/storage/postgres.rs @@ -10,6 +10,7 @@ use blockstack_lib::codec::StacksMessageCodec; use blockstack_lib::types::chainstate::StacksBlockId; use sqlx::PgExecutor; +use crate::bitcoin::utxo::SignerUtxo; use crate::error::Error; use crate::keys::PublicKey; use crate::stacks::events::CompletedDepositEvent; @@ -943,6 +944,14 @@ impl super::DbRead for PgStore { .map_err(Error::SqlxQuery) } + async fn get_signer_utxo( + &self, + _chain_tip: &model::BitcoinBlockHash, + _aggregate_key: &PublicKey, + ) -> Result, Error> { + unimplemented!() // TODO(538) + } + async fn in_canonical_bitcoin_blockchain( &self, chain_tip: &model::BitcoinBlockRef, diff --git a/signer/src/testing/api_clients.rs b/signer/src/testing/api_clients.rs index f907259f..dc6355bd 100644 --- a/signer/src/testing/api_clients.rs +++ b/signer/src/testing/api_clients.rs @@ -48,13 +48,6 @@ impl BitcoinInteract for NoopApiClient { unimplemented!() } - async fn get_signer_utxo( - &self, - _aggregate_key: &crate::keys::PublicKey, - ) -> Result, Error> { - unimplemented!() - } - async fn get_last_fee( &self, _utxo: bitcoin::OutPoint, diff --git a/signer/src/testing/context.rs b/signer/src/testing/context.rs index d5df4514..0ab144ab 100644 --- a/signer/src/testing/context.rs +++ b/signer/src/testing/context.rs @@ -165,13 +165,6 @@ impl BitcoinInteract for WrappedMock { self.inner.lock().await.estimate_fee_rate().await } - async fn get_signer_utxo( - &self, - aggregate_key: &crate::keys::PublicKey, - ) -> Result, Error> { - self.inner.lock().await.get_signer_utxo(aggregate_key).await - } - async fn get_last_fee( &self, utxo: bitcoin::OutPoint, diff --git a/signer/src/testing/storage/model.rs b/signer/src/testing/storage/model.rs index f17ce550..5d3c4fd6 100644 --- a/signer/src/testing/storage/model.rs +++ b/signer/src/testing/storage/model.rs @@ -1,5 +1,9 @@ //! Test data generation utilities +use std::collections::HashSet; + +use bitcoin::consensus::Encodable as _; +use bitcoin::hashes::Hash as _; use fake::Fake; use crate::keys::PublicKey; @@ -59,7 +63,7 @@ impl TestData { let mut test_data = Self::new(); for _ in 0..params.num_bitcoin_blocks { - let next_chunk = test_data.new_block(rng, signer_keys, params); + let (next_chunk, _) = test_data.new_block(rng, signer_keys, params, None); test_data.push(next_chunk); } @@ -68,11 +72,17 @@ impl TestData { /// Generate a new bitcoin block with associated data on top of /// the current model. - pub fn new_block(&self, rng: &mut R, signer_keys: &[PublicKey], params: &Params) -> Self + pub fn new_block( + &self, + rng: &mut R, + signer_keys: &[PublicKey], + params: &Params, + parent: Option<&BitcoinBlockRef>, + ) -> (Self, BitcoinBlockRef) where R: rand::RngCore, { - let mut block = self.generate_bitcoin_block(rng); + let mut block = self.generate_bitcoin_block(rng, parent); let stacks_blocks = self.generate_stacks_blocks(rng, &block, params.num_stacks_blocks_per_bitcoin_block); @@ -106,17 +116,20 @@ impl TestData { let bitcoin_blocks = vec![block.clone()]; - Self { - bitcoin_blocks, - stacks_blocks, - deposit_requests: deposit_data.deposit_requests, - deposit_signers: deposit_data.deposit_signers, - withdraw_requests: withdraw_data.withdraw_requests, - withdraw_signers: withdraw_data.withdraw_signers, - bitcoin_transactions: deposit_data.bitcoin_transactions, - stacks_transactions: withdraw_data.stacks_transactions, - transactions, - } + ( + Self { + bitcoin_blocks, + stacks_blocks, + deposit_requests: deposit_data.deposit_requests, + deposit_signers: deposit_data.deposit_signers, + withdraw_requests: withdraw_data.withdraw_requests, + withdraw_signers: withdraw_data.withdraw_signers, + bitcoin_transactions: deposit_data.bitcoin_transactions, + stacks_transactions: withdraw_data.stacks_transactions, + transactions, + }, + block.into(), + ) } /// Add newly generated data to the current model. @@ -134,6 +147,51 @@ impl TestData { self.transactions.extend(new_data.transactions); } + /// Remove data in `other` present in the current model. + pub fn remove(&mut self, other: Self) { + vec_diff(&mut self.bitcoin_blocks, &other.bitcoin_blocks); + vec_diff(&mut self.stacks_blocks, &other.stacks_blocks); + vec_diff(&mut self.deposit_requests, &other.deposit_requests); + vec_diff(&mut self.deposit_signers, &other.deposit_signers); + vec_diff(&mut self.withdraw_requests, &other.withdraw_requests); + vec_diff(&mut self.withdraw_signers, &other.withdraw_signers); + vec_diff(&mut self.bitcoin_transactions, &other.bitcoin_transactions); + vec_diff(&mut self.stacks_transactions, &other.stacks_transactions); + vec_diff(&mut self.transactions, &other.transactions); + } + + /// Push sbtc txs to a specific bitcoin block + pub fn push_sbtc_txs(&mut self, block: &BitcoinBlockRef, sbtc_txs: Vec) { + let mut bitcoin_transactions = vec![]; + let mut transactions = vec![]; + + for tx in sbtc_txs { + let mut tx_bytes = Vec::new(); + tx.consensus_encode(&mut tx_bytes).unwrap(); + + let tx = model::Transaction { + txid: tx.compute_txid().to_byte_array(), + tx: tx_bytes, + tx_type: model::TransactionType::SbtcTransaction, + block_hash: block.block_hash.into_bytes(), + }; + + let bitcoin_transaction = model::BitcoinTxRef { + txid: tx.txid.into(), + block_hash: block.block_hash, + }; + + transactions.push(tx); + bitcoin_transactions.push(bitcoin_transaction); + } + + self.push(Self { + bitcoin_transactions, + transactions, + ..Self::default() + }); + } + /// Write the test data to the given store. pub async fn write_to(&self, storage: &Db) where @@ -203,13 +261,20 @@ impl TestData { } } - fn generate_bitcoin_block(&self, rng: &mut impl rand::RngCore) -> model::BitcoinBlock { + fn generate_bitcoin_block( + &self, + rng: &mut impl rand::RngCore, + parent: Option<&BitcoinBlockRef>, + ) -> model::BitcoinBlock { let mut block: model::BitcoinBlock = fake::Faker.fake_with_rng(rng); - let parent_block_summary = self - .bitcoin_blocks - .choose(rng) - .map(BitcoinBlockRef::summarize) - .unwrap_or_else(|| BitcoinBlockRef::hallucinate_parent(&block)); + let parent_block_summary = match parent { + Some(block) => block, + None => &self + .bitcoin_blocks + .choose(rng) + .map(BitcoinBlockRef::summarize) + .unwrap_or_else(|| BitcoinBlockRef::hallucinate_parent(&block)), + }; block.parent_hash = parent_block_summary.block_hash; block.block_height = parent_block_summary.block_height + 1; @@ -453,3 +518,8 @@ impl StacksBlockSummary { } } } + +fn vec_diff(subtrahend: &mut Vec, minuend: &[T]) { + let minuend_set = minuend.iter().collect::>(); + subtrahend.retain(|v| !minuend_set.contains(v)); +} diff --git a/signer/src/testing/transaction_coordinator.rs b/signer/src/testing/transaction_coordinator.rs index 78c1416e..beb20330 100644 --- a/signer/src/testing/transaction_coordinator.rs +++ b/signer/src/testing/transaction_coordinator.rs @@ -1,11 +1,12 @@ //! Test utilities for the transaction coordinator +use std::cell::RefCell; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; -use crate::bitcoin::utxo; +use crate::bitcoin::utxo::SignerUtxo; use crate::bitcoin::MockBitcoinInteract; use crate::context::Context; use crate::context::SignerEvent; @@ -29,6 +30,13 @@ use sha2::Digest as _; use super::context::TestContext; use super::context::WrappedMock; +const EMPTY_BITCOIN_TX: bitcoin::Transaction = bitcoin::Transaction { + version: bitcoin::transaction::Version::ONE, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![], + output: vec![], +}; + struct EventLoopHarness { event_loop: EventLoop, context: C, @@ -116,21 +124,23 @@ impl TestEnvironment>> { network.connect() }); - let (aggregate_key, bitcoin_chain_tip) = self + let (aggregate_key, bitcoin_chain_tip, mut test_data) = self .prepare_database_and_run_dkg(&mut rng, &mut testing_signer_set) .await; - let public_key = bitcoin::XOnlyPublicKey::from(&aggregate_key); - let outpoint = bitcoin::OutPoint { - txid: testing::dummy::txid(&fake::Faker, &mut rng), - vout: 3, - }; + let original_test_data = test_data.clone(); - let signer_utxo = utxo::SignerUtxo { - outpoint, - amount: 1_337_000_000_000, - public_key, + let tx_1 = bitcoin::Transaction { + output: vec![bitcoin::TxOut { + value: bitcoin::Amount::from_sat(1_337_000_000_000), + script_pubkey: aggregate_key.signers_script_pubkey(), + }], + ..EMPTY_BITCOIN_TX }; + test_data.push_sbtc_txs(&bitcoin_chain_tip, vec![tx_1.clone()]); + + test_data.remove(original_test_data); + self.write_test_data(&test_data).await; self.context .with_bitcoin_client(|client| { @@ -139,11 +149,6 @@ impl TestEnvironment>> { .times(1) .returning(|| Box::pin(async { Ok(1.3) })); - client - .expect_get_signer_utxo() - .once() - .returning(move |_| Box::pin(async move { Ok(Some(signer_utxo)) })); - client .expect_get_last_fee() .once() @@ -184,7 +189,7 @@ impl TestEnvironment>> { }); // Get the private key of the coordinator of the signer set. - let private_key = Self::select_coordinator(&bitcoin_chain_tip, &signer_info); + let private_key = Self::select_coordinator(&bitcoin_chain_tip.block_hash, &signer_info); // Bootstrap the tx coordinator within an event loop harness. let event_loop_harness = EventLoopHarness::create( @@ -231,11 +236,270 @@ impl TestEnvironment>> { assert_eq!(first_script_pubkey, aggregate_key.signers_script_pubkey()); } + /// Assert we get the correct UTXO in a simple case + pub async fn assert_get_signer_utxo_simple(mut self) { + let mut rng = rand::rngs::StdRng::seed_from_u64(46); + let network = network::in_memory::Network::new(); + let signer_info = testing::wsts::generate_signer_info(&mut rng, self.num_signers); + + let mut signer_set = + testing::wsts::SignerSet::new(&signer_info, self.signing_threshold as u32, || { + network.connect() + }); + + let (aggregate_key, bitcoin_chain_tip, mut test_data) = self + .prepare_database_and_run_dkg(&mut rng, &mut signer_set) + .await; + + let original_test_data = test_data.clone(); + + let tx = bitcoin::Transaction { + output: vec![ + bitcoin::TxOut { + value: bitcoin::Amount::from_sat(42), + script_pubkey: aggregate_key.signers_script_pubkey(), + }, + bitcoin::TxOut { + value: bitcoin::Amount::from_sat(123), + script_pubkey: bitcoin::ScriptBuf::new(), + }, + ], + ..EMPTY_BITCOIN_TX + }; + + let (block, block_ref) = test_data.new_block( + &mut rng, + &signer_set.signer_keys(), + &self.test_model_parameters, + Some(&bitcoin_chain_tip), + ); + test_data.push(block); + test_data.push_sbtc_txs(&block_ref, vec![tx.clone()]); + + let expected = SignerUtxo { + outpoint: bitcoin::OutPoint::new(tx.compute_txid(), 0), + amount: 42, + public_key: bitcoin::XOnlyPublicKey::from(aggregate_key), + }; + + test_data.remove(original_test_data); + self.write_test_data(&test_data).await; + + let chain_tip = self + .context + .get_storage() + .get_bitcoin_canonical_chain_tip() + .await + .expect("storage failure") + .expect("missing block"); + assert_eq!(chain_tip, block_ref.block_hash); + + let signer_utxo = self + .context + .get_storage() + .get_signer_utxo(&chain_tip, &aggregate_key) + .await + .unwrap() + .expect("no signer utxo"); + + assert_eq!(signer_utxo, expected); + } + + /// Assert we get the correct UTXO in a fork + pub async fn assert_get_signer_utxo_fork(mut self) { + let mut rng = rand::rngs::StdRng::seed_from_u64(46); + let network = network::in_memory::Network::new(); + let signer_info = testing::wsts::generate_signer_info(&mut rng, self.num_signers); + + let mut signer_set = + testing::wsts::SignerSet::new(&signer_info, self.signing_threshold as u32, || { + network.connect() + }); + + let (aggregate_key, bitcoin_chain_tip, test_data) = self + .prepare_database_and_run_dkg(&mut rng, &mut signer_set) + .await; + + let original_test_data = test_data.clone(); + + let test_data_rc = RefCell::new(test_data); + let mut push_block = |parent| { + let (block, block_ref) = test_data_rc.borrow_mut().new_block( + &mut rng, + &signer_set.signer_keys(), + &self.test_model_parameters, + Some(parent), + ); + test_data_rc.borrow_mut().push(block); + block_ref + }; + let push_utxo = |block_ref, sat_amt| { + let tx = bitcoin::Transaction { + output: vec![bitcoin::TxOut { + value: bitcoin::Amount::from_sat(sat_amt), + script_pubkey: aggregate_key.signers_script_pubkey(), + }], + ..EMPTY_BITCOIN_TX + }; + test_data_rc + .borrow_mut() + .push_sbtc_txs(block_ref, vec![tx.clone()]); + tx + }; + + // The scenario is: (* = no utxo) + // [bitcoin_chain_tip] +- [block a1] - [block a2] - [block a3*] + // +- [block b1] - [block b2] - [block b3*] + // +- [block c1] - [block c2*] + + let block_a1 = push_block(&bitcoin_chain_tip); + let tx_a1 = push_utxo(&block_a1, 0xA1); + + let block_a2 = push_block(&block_a1); + let tx_a2 = push_utxo(&block_a2, 0xA2); + + let block_a3 = push_block(&block_a2); + + let block_b1 = push_block(&bitcoin_chain_tip); + let tx_b1 = push_utxo(&block_b1, 0xB1); + + let block_b2 = push_block(&block_b1); + let tx_b2 = push_utxo(&block_b2, 0xB2); + + let block_b3 = push_block(&block_b2); + + let block_c1 = push_block(&bitcoin_chain_tip); + let tx_c1 = push_utxo(&block_c1, 0xC1); + + let block_c2 = push_block(&block_c1); + + let mut test_data = test_data_rc.into_inner(); + test_data.remove(original_test_data); + self.write_test_data(&test_data).await; + + for (chain_tip, tx, amt) in [ + (&block_a1, &tx_a1, 0xA1), + (&block_a2, &tx_a2, 0xA2), + (&block_a3, &tx_a2, 0xA2), + (&block_b1, &tx_b1, 0xB1), + (&block_b2, &tx_b2, 0xB2), + (&block_b3, &tx_b2, 0xB2), + (&block_c1, &tx_c1, 0xC1), + (&block_c2, &tx_c1, 0xC1), + ] { + let expected = SignerUtxo { + outpoint: bitcoin::OutPoint::new(tx.compute_txid(), 0), + amount: amt, + public_key: bitcoin::XOnlyPublicKey::from(aggregate_key), + }; + let signer_utxo = self + .context + .get_storage() + .get_signer_utxo(&chain_tip.block_hash, &aggregate_key) + .await + .unwrap() + .expect("no signer utxo"); + assert_eq!(signer_utxo, expected); + } + } + + /// Assert we get the correct UTXO with a spending chain in a block + pub async fn assert_get_signer_utxo_unspent(mut self) { + let mut rng = rand::rngs::StdRng::seed_from_u64(46); + let network = network::in_memory::Network::new(); + let signer_info = testing::wsts::generate_signer_info(&mut rng, self.num_signers); + + let mut signer_set = + testing::wsts::SignerSet::new(&signer_info, self.signing_threshold as u32, || { + network.connect() + }); + + let (aggregate_key, bitcoin_chain_tip, mut test_data) = self + .prepare_database_and_run_dkg(&mut rng, &mut signer_set) + .await; + + let original_test_data = test_data.clone(); + + let tx_1 = bitcoin::Transaction { + output: vec![bitcoin::TxOut { + value: bitcoin::Amount::from_sat(1), + script_pubkey: aggregate_key.signers_script_pubkey(), + }], + ..EMPTY_BITCOIN_TX + }; + let tx_2 = bitcoin::Transaction { + output: vec![bitcoin::TxOut { + value: bitcoin::Amount::from_sat(2), + script_pubkey: aggregate_key.signers_script_pubkey(), + }], + ..EMPTY_BITCOIN_TX + }; + let tx_3 = bitcoin::Transaction { + input: vec![ + bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: tx_1.compute_txid(), + vout: 0, + }, + ..Default::default() + }, + bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: tx_2.compute_txid(), + vout: 0, + }, + ..Default::default() + }, + ], + output: vec![bitcoin::TxOut { + value: bitcoin::Amount::from_sat(3), + script_pubkey: aggregate_key.signers_script_pubkey(), + }], + ..EMPTY_BITCOIN_TX + }; + let (block, block_ref) = test_data.new_block( + &mut rng, + &signer_set.signer_keys(), + &self.test_model_parameters, + Some(&bitcoin_chain_tip), + ); + test_data.push(block); + test_data.push_sbtc_txs(&block_ref, vec![tx_1.clone(), tx_3.clone(), tx_2.clone()]); + + let expected = SignerUtxo { + outpoint: bitcoin::OutPoint::new(tx_3.compute_txid(), 0), + amount: 3, + public_key: bitcoin::XOnlyPublicKey::from(aggregate_key), + }; + + test_data.remove(original_test_data); + self.write_test_data(&test_data).await; + + let chain_tip = self + .context + .get_storage() + .get_bitcoin_canonical_chain_tip() + .await + .expect("storage failure") + .expect("missing block"); + assert_eq!(chain_tip, block_ref.block_hash); + + let signer_utxo = self + .context + .get_storage() + .get_signer_utxo(&chain_tip, &aggregate_key) + .await + .unwrap() + .expect("no signer utxo"); + + assert_eq!(signer_utxo, expected); + } + async fn prepare_database_and_run_dkg( &mut self, rng: &mut Rng, signer_set: &mut SignerSet, - ) -> (keys::PublicKey, model::BitcoinBlockHash) + ) -> (keys::PublicKey, model::BitcoinBlockRef, TestData) where Rng: rand::CryptoRng + rand::RngCore, { @@ -251,6 +515,13 @@ impl TestEnvironment>> { .expect("storage error") .expect("no chain tip"); + let bitcoin_chain_tip_ref = storage + .get_bitcoin_block(&bitcoin_chain_tip) + .await + .expect("storage failure") + .expect("missing block") + .into(); + let dkg_txid = testing::dummy::txid(&fake::Faker, rng); let (aggregate_key, all_dkg_shares) = signer_set.run_dkg(bitcoin_chain_tip, dkg_txid, rng).await; @@ -271,7 +542,7 @@ impl TestEnvironment>> { .await .expect("failed to write encrypted shares"); - (aggregate_key, bitcoin_chain_tip) + (aggregate_key, bitcoin_chain_tip_ref, test_data) } async fn write_test_data(&self, test_data: &TestData) { diff --git a/signer/src/transaction_coordinator.rs b/signer/src/transaction_coordinator.rs index d0d4e5d1..43be17ce 100644 --- a/signer/src/transaction_coordinator.rs +++ b/signer/src/transaction_coordinator.rs @@ -424,8 +424,18 @@ where ) -> Result { let bitcoin_client = self.context.get_bitcoin_client(); let fee_rate = bitcoin_client.estimate_fee_rate().await?; - let utxo = bitcoin_client - .get_signer_utxo(aggregate_key) + let Some(chain_tip) = self + .context + .get_storage() + .get_bitcoin_canonical_chain_tip() + .await? + else { + return Err(Error::NoChainTip); + }; + let utxo = self + .context + .get_storage() + .get_signer_utxo(&chain_tip, aggregate_key) .await? .ok_or(Error::MissingSignerUtxo)?; let last_fees = bitcoin_client.get_last_fee(utxo.outpoint).await?; @@ -613,4 +623,19 @@ mod tests { .assert_should_be_able_to_coordinate_signing_rounds() .await; } + + #[tokio::test] + async fn should_get_signer_utxo_simple() { + test_environment().assert_get_signer_utxo_simple().await; + } + + #[tokio::test] + async fn should_get_signer_utxo_fork() { + test_environment().assert_get_signer_utxo_fork().await; + } + + #[tokio::test] + async fn should_get_signer_utxo_unspent() { + test_environment().assert_get_signer_utxo_unspent().await; + } } diff --git a/signer/tests/integration/bitcoin_client.rs b/signer/tests/integration/bitcoin_client.rs index ab58a245..b4709250 100644 --- a/signer/tests/integration/bitcoin_client.rs +++ b/signer/tests/integration/bitcoin_client.rs @@ -9,6 +9,7 @@ use signer::bitcoin::BitcoinInteract; use signer::util::ApiFallbackClient; use url::Url; +#[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn test_get_block_not_found() { let url: Url = "http://devnet:devnet@localhost:18443".parse().unwrap(); @@ -26,6 +27,7 @@ async fn test_get_block_not_found() { // TODO: Figure out how to let this (and similar tests) run against the wallet // generated by `initialize_blockchain()`. See comment in the test below. //#[ignore = "This test needs to be run against a 'fresh' bitcoin core instance"] +#[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn test_get_block_works() { let (_, faucet) = regtest::initialize_blockchain();