diff --git a/protobufs/stacks/signer/v1/requests.proto b/protobufs/stacks/signer/v1/requests.proto index 8dbe553e..4b3b1e06 100644 --- a/protobufs/stacks/signer/v1/requests.proto +++ b/protobufs/stacks/signer/v1/requests.proto @@ -74,8 +74,8 @@ message AcceptWithdrawal { // The outpoint of the bitcoin UTXO that was spent to fulfill the // withdrawal request. bitcoin.OutPoint outpoint = 2; - // The fee that was spent to the bitcoin miner when fulfilling the - // withdrawal request. + // This is the assessed transaction fee for fulfilling the withdrawal + // request. uint64 tx_fee = 3; // A bitmap of how the signers voted. The length of the list must be less // than or equal to 128. Here, we assume that a true implies that the diff --git a/sbtc/src/testing/regtest.rs b/sbtc/src/testing/regtest.rs index f899ef42..5d31b041 100644 --- a/sbtc/src/testing/regtest.rs +++ b/sbtc/src/testing/regtest.rs @@ -48,6 +48,11 @@ pub const BITCOIN_CORE_RPC_PASSWORD: &str = "devnet"; /// The fallback fee in bitcoin core pub const BITCOIN_CORE_FALLBACK_FEE: Amount = Amount::from_sat(1000); +/// The minimum height of the bitcoin blockchains. You need 100 blocks +/// mined on top of a bitcoin block before you can spend the coinbase +/// rewards. +pub const MIN_BLOCKCHAIN_HEIGHT: u64 = 101; + /// The name of our wallet on bitcoin-core const BITCOIN_CORE_WALLET_NAME: &str = "integration-tests-wallet"; @@ -85,7 +90,7 @@ pub fn initialize_blockchain() -> (&'static Client, &'static Faucet) { let amount = rpc.get_received_by_address(&faucet.address, None).unwrap(); if amount < Amount::from_int_btc(1) { - faucet.generate_blocks(101); + faucet.generate_blocks(MIN_BLOCKCHAIN_HEIGHT); } faucet diff --git a/signer/src/bitcoin/mod.rs b/signer/src/bitcoin/mod.rs index 02733c73..1a0f3062 100644 --- a/signer/src/bitcoin/mod.rs +++ b/signer/src/bitcoin/mod.rs @@ -32,7 +32,7 @@ pub trait BitcoinInteract: Sync + Send { txid: &Txid, ) -> impl Future, Error>> + Send; - /// get tx info + /// Get a transaction with additional information about it. fn get_tx_info( &self, txid: &Txid, diff --git a/signer/src/bitcoin/rpc.rs b/signer/src/bitcoin/rpc.rs index 3a9259b3..ff18e094 100644 --- a/signer/src/bitcoin/rpc.rs +++ b/signer/src/bitcoin/rpc.rs @@ -131,8 +131,12 @@ pub struct BitcoinTxInfoVin { /// Most of the details to the input into the transaction #[serde(flatten)] pub details: GetRawTransactionResultVin, - /// The previous output, omitted if block undo data is not available. - pub prevout: Option, + /// The previous output. + /// + /// This field is omitted if block undo data is not available, so it is + /// missing whenever the `fee` field is missing in the + /// [`BitcoinTxInfo`]. + pub prevout: BitcoinTxInfoVinPrevout, } /// The previous output, omitted if block undo data is not available. @@ -240,10 +244,6 @@ impl BitcoinCoreClient { match self.inner.call::("getrawtransaction", &args) { Ok(tx_info) => Ok(Some(tx_info)), - // If the transaction is not found in an - // actual block then the message is "No such transaction found - // in the provided block. Use gettransaction for wallet - // transactions." In both cases the code is the same. Err(BtcRpcError::JsonRpc(JsonRpcError::Rpc(RpcError { code: -5, .. }))) => Ok(None), Err(err) => Err(Error::BitcoinCoreGetTransaction(err, *txid)), } @@ -275,7 +275,7 @@ impl BitcoinCoreClient { // If the `block_hash` is not found then the message is "Block // hash not found", while if the transaction is not found in an // actual block then the message is "No such transaction found - // in the provided block. Use gettransaction for wallet + // in the provided block. Use `gettransaction` for wallet // transactions." In both cases the code is the same. Err(BtcRpcError::JsonRpc(JsonRpcError::Rpc(RpcError { code: -5, .. }))) => Ok(None), Err(err) => Err(Error::BitcoinCoreGetTransaction(err, *txid)), diff --git a/signer/src/bitcoin/utxo.rs b/signer/src/bitcoin/utxo.rs index 93fda8c0..c6010a2f 100644 --- a/signer/src/bitcoin/utxo.rs +++ b/signer/src/bitcoin/utxo.rs @@ -953,7 +953,7 @@ impl BitcoinTxInfo { /// /// The logic for the fee assessment is from /// . - pub fn assess_input_fee(&self, outpoint: OutPoint) -> Option { + pub fn assess_input_fee(&self, outpoint: &OutPoint) -> Option { // The Weight::to_wu function just returns the inner weight units // as an u64, so this is really just the weight. let request_weight = self.request_weight().to_wu(); @@ -964,7 +964,7 @@ impl BitcoinTxInfo { .input .iter() .skip(1) - .find(|tx_in| tx_in.previous_output == outpoint)? + .find(|tx_in| &tx_in.previous_output == outpoint)? .segwit_weight() .to_wu(); @@ -2267,7 +2267,7 @@ mod tests { let fee = Amount::from_sat(500_000); let tx_info = BitcoinTxInfo::from_tx(tx, fee); - let assessed_fee = tx_info.assess_input_fee(deposit_outpoint).unwrap(); + let assessed_fee = tx_info.assess_input_fee(&deposit_outpoint).unwrap(); assert_eq!(assessed_fee, fee); } @@ -2300,7 +2300,7 @@ mod tests { // `base_signer_transaction()` only adds one input, the search for // the given input when `assess_input_fee` executes will always // fail, simulating that the specified outpoint wasn't found. - assert!(tx_info.assess_input_fee(OutPoint::null()).is_none()); + assert!(tx_info.assess_input_fee(&OutPoint::null()).is_none()); } #[test] @@ -2330,10 +2330,10 @@ mod tests { let fee = Amount::from_sat(500_000); let tx_info = BitcoinTxInfo::from_tx(tx, fee); - let assessed_fee1 = tx_info.assess_input_fee(deposit_outpoint1).unwrap(); + let assessed_fee1 = tx_info.assess_input_fee(&deposit_outpoint1).unwrap(); assert_eq!(assessed_fee1, fee / 2); - let assessed_fee2 = tx_info.assess_input_fee(deposit_outpoint2).unwrap(); + let assessed_fee2 = tx_info.assess_input_fee(&deposit_outpoint2).unwrap(); assert_eq!(assessed_fee2, fee / 2); } @@ -2387,7 +2387,7 @@ mod tests { let fee = Amount::from_sat(fee_sats); let tx_info = BitcoinTxInfo::from_tx(tx, fee); - let input_assessed_fee = tx_info.assess_input_fee(deposit_outpoint).unwrap(); + let input_assessed_fee = tx_info.assess_input_fee(&deposit_outpoint).unwrap(); let output1_assessed_fee = tx_info.assess_output_fee(2).unwrap(); let output2_assessed_fee = tx_info.assess_output_fee(3).unwrap(); diff --git a/signer/src/block_observer.rs b/signer/src/block_observer.rs index eedf8b07..08fbedf3 100644 --- a/signer/src/block_observer.rs +++ b/signer/src/block_observer.rs @@ -359,7 +359,9 @@ mod tests { use blockstack_lib::types::chainstate::StacksAddress; use blockstack_lib::types::chainstate::StacksBlockId; use fake::Dummy; + use fake::Fake; use model::BitcoinTxId; + use model::ScriptPubKey; use rand::seq::IteratorRandom; use rand::SeedableRng; @@ -626,7 +628,7 @@ mod tests { // 4. We try "extracting" a block with two transactions where one // of them spends to the signers. The one transaction should be // stored in our storage. - let signers_script_pubkey = vec![1, 2, 3, 4]; + let signers_script_pubkey: ScriptPubKey = fake::Faker.fake_with_rng(&mut rng); // We start by storing our `scriptPubKey`. let storage = storage::in_memory::Store::new_shared(); @@ -655,7 +657,7 @@ mod tests { let mut tx_setup0 = sbtc::testing::deposits::tx_setup(0, 0, 100); tx_setup0.tx.output.push(TxOut { value: Amount::ONE_BTC, - script_pubkey: ScriptBuf::from_bytes(signers_script_pubkey.clone()), + script_pubkey: signers_script_pubkey.into(), }); // This one does not spend to the signers :( diff --git a/signer/src/config/default.toml b/signer/src/config/default.toml index 15d6f232..b76bf3ab 100644 --- a/signer/src/config/default.toml +++ b/signer/src/config/default.toml @@ -45,7 +45,7 @@ endpoints = [ # Environment: SIGNER_BITCOIN__RPC_ENDPOINTS # Environment Example: http://user:pass@seed-1:4122,http://foo:bar@seed-2:4122 rpc_endpoints = [ - "http://user:pass@localhost:18443", + "http://devnet:devnet@localhost:18443", ] # The URI(s) of the Bitcoin Core ZMQ block hash stream(s) to connect to. diff --git a/signer/src/config/mod.rs b/signer/src/config/mod.rs index 6ff31cd3..c6320f10 100644 --- a/signer/src/config/mod.rs +++ b/signer/src/config/mod.rs @@ -410,10 +410,10 @@ mod tests { assert_eq!( settings.bitcoin.rpc_endpoints, - vec![url("http://user:pass@localhost:18443")] + vec![url("http://devnet:devnet@localhost:18443")] ); - assert_eq!(settings.bitcoin.rpc_endpoints[0].username(), "user"); - assert_eq!(settings.bitcoin.rpc_endpoints[0].password(), Some("pass")); + assert_eq!(settings.bitcoin.rpc_endpoints[0].username(), "devnet"); + assert_eq!(settings.bitcoin.rpc_endpoints[0].password(), Some("devnet")); assert_eq!( settings.signer.event_observer.bind, "0.0.0.0:8801".parse::().unwrap() diff --git a/signer/src/context/mod.rs b/signer/src/context/mod.rs index 2bd3a94c..546f0255 100644 --- a/signer/src/context/mod.rs +++ b/signer/src/context/mod.rs @@ -69,7 +69,7 @@ pub struct SignerContext { impl SignerContext where S: DbRead + DbWrite + Clone + Sync + Send, - BC: for<'a> TryFrom<&'a [Url]> + BitcoinInteract + Clone + Sync + Send + 'static, + BC: for<'a> TryFrom<&'a [Url]> + BitcoinInteract + Clone + 'static, ST: for<'a> TryFrom<&'a Settings> + StacksInteract + Clone + Sync + Send + 'static, Error: for<'a> From<>::Error>, Error: for<'a> From<>::Error>, @@ -87,7 +87,7 @@ where impl SignerContext where S: DbRead + DbWrite + Clone + Sync + Send, - BC: BitcoinInteract + Clone + Sync + Send, + BC: BitcoinInteract + Clone, ST: StacksInteract + Clone + Sync + Send, { /// Create a new signer context. @@ -112,7 +112,7 @@ where impl Context for SignerContext where S: DbRead + DbWrite + Clone + Sync + Send, - BC: BitcoinInteract + Clone + Sync + Send, + BC: BitcoinInteract + Clone, ST: StacksInteract + Clone + Sync + Send, { fn config(&self) -> &Settings { diff --git a/signer/src/proto/generated/stacks.signer.v1.rs b/signer/src/proto/generated/stacks.signer.v1.rs index d0549b52..f27fa6db 100644 --- a/signer/src/proto/generated/stacks.signer.v1.rs +++ b/signer/src/proto/generated/stacks.signer.v1.rs @@ -161,8 +161,8 @@ pub struct AcceptWithdrawal { /// withdrawal request. #[prost(message, optional, tag = "2")] pub outpoint: ::core::option::Option, - /// The fee that was spent to the bitcoin miner when fulfilling the - /// withdrawal request. + /// This is the assessed transaction fee for fulfilling the withdrawal + /// request. #[prost(uint64, tag = "3")] pub tx_fee: u64, /// A bitmap of how the signers voted. The length of the list must be less diff --git a/signer/src/stacks/contracts.rs b/signer/src/stacks/contracts.rs index 1d1d3e0d..bb30abe2 100644 --- a/signer/src/stacks/contracts.rs +++ b/signer/src/stacks/contracts.rs @@ -22,7 +22,9 @@ use std::ops::Deref; use std::sync::OnceLock; use bitcoin::hashes::Hash as _; +use bitcoin::Amount; use bitcoin::OutPoint; +use bitcoin::ScriptBuf; use bitcoin::TxOut; use bitvec::array::BitArray; use bitvec::field::BitField as _; @@ -41,6 +43,7 @@ use blockstack_lib::clarity::vm::ContractName; use blockstack_lib::clarity::vm::Value as ClarityValue; use blockstack_lib::types::chainstate::StacksAddress; +use crate::bitcoin::BitcoinInteract; use crate::context::Context; use crate::error::Error; use crate::keys::PublicKey; @@ -276,7 +279,11 @@ impl AsContractCall for CompleteDepositV1 { /// 5. That the recipients in the transaction matches that of the /// deposit request. /// 6. That the amount to mint does not exceed the deposit amount. - /// 7. That the fee is less than the desired max-fee. + /// 7. That the fee matches the expected assessed fee for the output. + /// 8. That the fee is less than the desired max-fee. + /// 9. That the first input into the sweep transaction is the signers' + /// UTXO. This checks that the sweep transaction was generated by + /// the signers. /// /// # Notes /// @@ -291,10 +298,11 @@ impl AsContractCall for CompleteDepositV1 { where C: Context + Send + Sync, { - // Covers points 3 & 4 - self.validate_sweep_tx(ctx, req_ctx).await?; - // Covers points 1-2 & 5-7 - self.validate_deposit_vars(ctx, req_ctx).await + // Covers points 3-4 & 9 + let fee = self.validate_sweep_tx(ctx, req_ctx).await?; + let db = ctx.get_storage(); + // Covers points 1-2 & 5-8 + self.validate_vars(&db, req_ctx, fee).await } } @@ -311,12 +319,15 @@ impl CompleteDepositV1 { /// 5. That the recipients in the transaction matches that of the /// deposit request. /// 6. That the amount to mint does not exceed the deposit amount. - /// 7. That the max-fee is less than the desired max-fee. - async fn validate_deposit_vars(&self, ctx: &C, req_ctx: &ReqContext) -> Result<(), Error> + /// 7. That the fee matches the expected assessed fee for the output. + /// 8. That the max-fee is less than the desired max-fee. + /// + /// The `fee` input variable is our calculation of the assessed fee for + /// the deposit. + async fn validate_vars(&self, db: &S, req_ctx: &ReqContext, fee: Amount) -> Result<(), Error> where - C: Context + Send + Sync, + S: DbRead + Send + Sync, { - let db = ctx.get_storage(); // 1. That the smart contract deployer matches the deployer in our // context. if self.deployer != req_ctx.deployer { @@ -350,14 +361,15 @@ impl CompleteDepositV1 { if self.amount > deposit_request.amount { return Err(DepositErrorMsg::InvalidMintAmount.into_error(req_ctx, self)); } - // 7. Check that the fee is less than the desired max-fee. + // 7. That the fee matches the expected assessed fee for the output. + if fee.to_sat() + self.amount != deposit_request.amount { + return Err(DepositErrorMsg::IncorrectFee.into_error(req_ctx, self)); + } + // 8. Check that the fee is less than the desired max-fee. // // The smart contract cannot check if we exceed the max fee. - // - // TODO(552): The better check is to compute what the fee should be - // and verify that it matches. - if deposit_request.amount - self.amount > deposit_request.max_fee { - return Err(DepositErrorMsg::InvalidFee.into_error(req_ctx, self)); + if fee.to_sat() > deposit_request.max_fee { + return Err(DepositErrorMsg::FeeTooHigh.into_error(req_ctx, self)); } Ok(()) @@ -371,16 +383,20 @@ impl CompleteDepositV1 { /// bitcoin blockchain. /// 4. Check that the sweep transaction uses the indicated deposit /// outpoint as an input. - async fn validate_sweep_tx(&self, ctx: &C, req_ctx: &ReqContext) -> Result<(), Error> + /// 9. That the first input into the sweep transaction is the signers' + /// UTXO. + async fn validate_sweep_tx(&self, ctx: &C, req_ctx: &ReqContext) -> Result where C: Context + Send + Sync, { let db = ctx.get_storage(); - // First we check that we have a record of the transaction. - let sweep_tx = db - .get_bitcoin_tx(&self.sweep_txid, &self.sweep_block_hash) - .await? - .ok_or_else(|| DepositErrorMsg::SweepTransactionMissing.into_error(req_ctx, self))?; + let rpc = ctx.get_bitcoin_client(); + // First we check that bitcoin-core has a record of the transaction + // where we think it should be. + let txid = &self.sweep_txid; + let Some(sweep_tx) = rpc.get_tx_info(txid, &self.sweep_block_hash).await? else { + return Err(DepositErrorMsg::SweepTransactionMissing.into_error(req_ctx, self)); + }; // 3. Check that the signer sweep transaction is on the canonical // bitcoin blockchain. // @@ -404,12 +420,37 @@ impl CompleteDepositV1 { // Okay great, we know that the sweep transaction exists on the // canonical bitcoin blockchain, we just need to do a simple check // of the transaction inputs. - let mut tx_inputs = sweep_tx.input.iter(); + let mut tx_inputs = sweep_tx.tx.input.iter(); if !tx_inputs.any(|tx_in| tx_in.previous_output == self.outpoint) { return Err(DepositErrorMsg::MissingFromSweep.into_error(req_ctx, self)); } - Ok(()) + // 9. That the first input into the sweep transaction is the + // signers' UTXO. + // + // There should be a `vin` entry for each input in the transaction, + // so this shouldn't ever error. + let script_pub_key = sweep_tx + .vin + .first() + .map(|x| ScriptBuf::from_bytes(x.prevout.script_pub_key.hex.clone())) + .ok_or_else(|| DepositErrorMsg::InvalidSweep.into_error(req_ctx, self))?; + + // The real check that this transaction was actually generated by + // the signers. + if !db.is_signer_script_pub_key(&script_pub_key.into()).await? { + return Err(DepositErrorMsg::InvalidSweep.into_error(req_ctx, self)); + } + + // None is only returned from BitcoinTxInfo::assess_output_fee when: + // a) The indicated output index is 0 or 1, since or those cannot + // be valid output indices for sweep transactions, or + // b) When the output index points to an output that is not in + // the transaction. + // Both cases indicate that the UTXO is missing from the transaction. + sweep_tx + .assess_input_fee(&self.outpoint) + .ok_or_else(|| DepositErrorMsg::MissingFromSweep.into_error(req_ctx, self)) } } @@ -449,7 +490,10 @@ pub enum DepositErrorMsg { DeployerMismatch, /// The fee paid to the bitcoin miners exceeded the max fee. #[error("fee paid to the bitcoin miners exceeded the max fee")] - InvalidFee, + FeeTooHigh, + /// The supplied fee does not match what is expected. + #[error("the supplied fee does not match what is expected")] + IncorrectFee, /// The amount to mint must not exceed the amount in the deposit /// request. #[error("amount to mint exceeded the amount in the deposit request")] @@ -458,6 +502,10 @@ pub enum DepositErrorMsg { /// transaction. #[error("deposit outpoint is missing from the indicated sweep transaction")] MissingFromSweep, + /// The transaction that swept in the funds must spend a UTXO that the + /// signers control. + #[error("the transaction that swept the funds was not one of the signers' transactions")] + InvalidSweep, /// The recipient did not match the recipient in our deposit request /// records. #[error("recipient did not match the recipient in our deposit request")] @@ -498,8 +546,9 @@ pub struct AcceptWithdrawalV1 { /// The outpoint of the bitcoin UTXO that was spent to fulfill the /// withdrawal request. pub outpoint: OutPoint, - /// The fee that was spent to the bitcoin miner when fulfilling the - /// withdrawal request. + /// Fulfilling the withdrawal request involved a transaction fee spent + /// to bitcoin miners, this the portion of that transaction fee that + /// was assessed to this request. pub tx_fee: u64, /// A bitmap of how the signers voted. This structure supports up to /// 128 distinct signers. Here, we assume that a 1 (or true) implies @@ -540,27 +589,30 @@ impl AsContractCall for AcceptWithdrawalV1 { /// Validates that the accept-withdrawal-request satisfies the /// following criteria: /// - /// 1. That the smart contract deployer matches the deployer in our - /// context. - /// 2. That the signer has a record of the withdrawal request in its - /// list of pending and accepted withdrawal requests. - /// 3. That the signer bitcoin transaction sweeping out the users' - /// funds is on the canonical bitcoin blockchain. - /// 4. That the sweep transaction has the UTXO indicated by the - /// `outpoint`. - /// 5. The `scriptPubKey` of the UTXO matches the one in the withdrawal - /// request. - /// 6. The `amount` of the UTXO matches the one in the withdrawal - /// request. - /// 7. That the fee is less than the desired max-fee. - /// 8. That the signer bitmap matches the bitmap from our records. + /// 1. That the smart contract deployer matches the deployer in our + /// context. + /// 2. That the signer has a record of the withdrawal request in its + /// list of pending and accepted withdrawal requests. + /// 3. That the signer bitcoin transaction sweeping out the users' + /// funds is on the canonical bitcoin blockchain. + /// 4. That the sweep transaction has the UTXO indicated by the + /// `outpoint`. + /// 5. The `scriptPubKey` of the UTXO matches the one in the + /// withdrawal request. + /// 6. The `amount` of the UTXO matches the one in the withdrawal + /// request. + /// 7. That the fee is less than the desired max-fee. + /// 8. That the fee matches the expected assessed fee for the output. + /// 9. That the first input into the sweep transaction is the signers' + /// UTXO. + /// 10. That the signer bitmap matches the bitmap from our records. async fn validate(&self, db: &C, req_ctx: &ReqContext) -> Result<(), Error> where C: Context + Send + Sync, { - // Covers points 3 & 4 + // Covers points 3-4 & 8-9 let tx_out = self.validate_sweep(db, req_ctx).await?; - // Covers points 1-2 & 5-8 + // Covers points 1-2 & 5-7, & 10 self.validate_utxo(db, req_ctx, tx_out).await } } @@ -572,16 +624,16 @@ impl AcceptWithdrawalV1 { /// /// Specifically, this function checks the following points (from the /// docs of [`AcceptWithdrawalV1::validate`]): - /// 1. That the smart contract deployer matches the deployer in our - /// context. - /// 2. That the signer has a record of the withdrawal request in its - /// list of pending and accepted withdrawal requests. - /// 5. The `scriptPubKey` of the UTXO matches the recipient in the - /// withdrawal request. - /// 6. The `amount` of the UTXO matches the one in the withdrawal - /// request. - /// 7. That the fee is less than the desired max-fee. - /// 8. That the signer bitmap matches the bitmap from our records. + /// 1. That the smart contract deployer matches the deployer in our + /// context. + /// 2. That the signer has a record of the withdrawal request in its + /// list of pending and accepted withdrawal requests. + /// 5. The `scriptPubKey` of the UTXO matches the recipient in the + /// withdrawal request. + /// 6. The `amount` of the UTXO matches the one in the withdrawal + /// request. + /// 7. That the fee is less than the desired max-fee. + /// 10. That the signer bitmap matches the bitmap from our records. async fn validate_utxo( &self, ctx: &C, @@ -629,14 +681,11 @@ impl AcceptWithdrawalV1 { // // The smart contract cannot check if we exceed the max fee, so we // do a check ourselves. - // - // TODO(552): The better check is to compute what the fee should be - // and verify that it matches. if self.tx_fee > request.max_fee { - return Err(WithdrawalErrorMsg::InvalidFee.into_error(req_ctx, self)); + return Err(WithdrawalErrorMsg::FeeTooHigh.into_error(req_ctx, self)); } - // 8. That the signer bitmap matches the bitmap formed from our - // records. + // 10. That the signer bitmap matches the bitmap formed from our + // records. let votes = db .get_withdrawal_request_signer_votes(&request.qualified_id(), &req_ctx.aggregate_key) .await?; @@ -654,17 +703,21 @@ impl AcceptWithdrawalV1 { /// funds is on the canonical bitcoin blockchain. /// 4. That the sweep transaction has the UTXO indicated by the /// outpoint. + /// 8. That the fee matches the expected assessed fee for the output. + /// 9. That the first input into the sweep transaction is the signers' + /// UTXO. async fn validate_sweep(&self, ctx: &C, req_ctx: &ReqContext) -> Result where C: Context + Send + Sync, { let db = ctx.get_storage(); - // First we check that we have a record of the transaction. - let txid = self.outpoint.txid.into(); - let sweep_tx = db - .get_bitcoin_tx(&txid, &self.sweep_block_hash) - .await? - .ok_or_else(|| WithdrawalErrorMsg::SweepTransactionMissing.into_error(req_ctx, self))?; + let rpc = ctx.get_bitcoin_client(); + // First we check that bitcoin-core has a record of the transaction + // where we think it should be. + let txid = &self.outpoint.txid; + let Some(sweep_tx) = rpc.get_tx_info(txid, &self.sweep_block_hash).await? else { + return Err(WithdrawalErrorMsg::SweepTransactionMissing.into_error(req_ctx, self)); + }; // 3. That the signer bitcoin transaction sweeping out the users' // funds is on the canonical bitcoin blockchain. // @@ -685,10 +738,40 @@ impl AcceptWithdrawalV1 { // 4. That the sweep transaction has the UTXO indicated by the // outpoint. // - // Okay great, we know that the sweep transaction exists on the - // canonical bitcoin blockchain, we just need to do a simple check - // of the transaction inputs. + // None is only returned from BitcoinTxInfo::assess_output_fee when: + // a) The indicated output index is 0 or 1, since or those cannot + // be valid output indices for sweep transactions, or + // b) When the output index points to an output that is not in + // the transaction. + // Both cases indicate that the UTXO is missing from the transaction. + let Some(expected_fee) = sweep_tx.assess_output_fee(self.outpoint.vout as usize) else { + return Err(WithdrawalErrorMsg::UtxoMissingFromSweep.into_error(req_ctx, self)); + }; + + // 8. That the fee matches the expected assessed fee for the output. + if expected_fee.to_sat() != self.tx_fee { + return Err(WithdrawalErrorMsg::IncorrectFee.into_error(req_ctx, self)); + }; + + // 9. That the first input into the sweep transaction is the + // signers' UTXO. + // + // There should be a `vin` entry for each input in the transaction, + // so this shouldn't ever error. + let script_pub_key = sweep_tx + .vin + .first() + .map(|x| ScriptBuf::from_bytes(x.prevout.script_pub_key.hex.clone())) + .ok_or_else(|| WithdrawalErrorMsg::InvalidSweep.into_error(req_ctx, self))?; + + // The real check that this transaction was actually generated by + // the signers. + if !db.is_signer_script_pub_key(&script_pub_key.into()).await? { + return Err(WithdrawalErrorMsg::InvalidSweep.into_error(req_ctx, self)); + } + sweep_tx + .tx .output .get(self.outpoint.vout as usize) .cloned() @@ -732,15 +815,22 @@ pub enum WithdrawalErrorMsg { #[error("bitmap does not match expected bitmap from")] BitmapMismatch, /// The smart contract deployer is fixed, so this should always match. - #[error("The deployer in the transaction does not match the expected deployer")] + #[error("the deployer in the transaction does not match the expected deployer")] DeployerMismatch, /// The fee paid to the bitcoin miners exceeded the max fee. #[error("fee paid to the bitcoin miners exceeded the max fee")] - InvalidFee, + FeeTooHigh, + /// The supplied fee does not match what is expected. + #[error("the supplied fee does not match what is expected")] + IncorrectFee, /// The amount to withdraw must equal the amount in the withdrawal /// request. #[error("amount to withdrawn exceeded the amount in the withdrawal request")] InvalidAmount, + /// The transaction that swept in the funds must spend a UTXO that the + /// signers control. + #[error("the transaction that swept the funds was not one of the signers' transactions")] + InvalidSweep, /// The recipient did not match the recipient in our withdrawal request /// records. #[error("recipient did not match the recipient in our withdrawal request")] diff --git a/signer/src/storage/in_memory.rs b/signer/src/storage/in_memory.rs index 8c5e8898..f61f8a3b 100644 --- a/signer/src/storage/in_memory.rs +++ b/signer/src/storage/in_memory.rs @@ -418,7 +418,7 @@ impl super::DbRead for SharedStore { .await .encrypted_dkg_shares .values() - .map(|share| share.script_pubkey.clone()) + .map(|share| share.script_pubkey.to_bytes()) .collect()) } @@ -563,6 +563,15 @@ impl super::DbRead for SharedStore { Ok(num_matches > 0) } + async fn is_signer_script_pub_key(&self, script: &model::ScriptPubKey) -> Result { + Ok(self + .lock() + .await + .encrypted_dkg_shares + .values() + .any(|share| &share.script_pubkey == script)) + } + async fn get_bitcoin_tx( &self, txid: &model::BitcoinTxId, diff --git a/signer/src/storage/mod.rs b/signer/src/storage/mod.rs index ce771139..b707afa1 100644 --- a/signer/src/storage/mod.rs +++ b/signer/src/storage/mod.rs @@ -127,7 +127,9 @@ pub trait DbRead { chain_tip: &model::BitcoinBlockHash, ) -> impl Future, Error>> + Send; - /// Get the last 365 days worth of the signers' `scriptPubkey`s. + /// Get the last 365 days worth of the signers' `scriptPubkey`s. If no + /// keys are available within the last 365, then return the most recent + /// key. fn get_signers_script_pubkeys( &self, ) -> impl Future, Error>> + Send; @@ -177,6 +179,13 @@ pub trait DbRead { block_ref: &model::BitcoinBlockRef, ) -> impl Future> + Send; + /// Checks whether the given scriptPubKey is one of the signers' + /// scriptPubKeys. + fn is_signer_script_pub_key( + &self, + script: &model::ScriptPubKey, + ) -> impl Future> + Send; + /// Fetch the bitcoin transaction that is included in the block /// identified by the block hash. fn get_bitcoin_tx( diff --git a/signer/src/storage/model.rs b/signer/src/storage/model.rs index 4a083452..a4764bb5 100644 --- a/signer/src/storage/model.rs +++ b/signer/src/storage/model.rs @@ -247,7 +247,7 @@ pub struct EncryptedDkgShares { /// The tweaked aggregate key for these shares pub tweaked_aggregate_key: PublicKey, /// The `scriptPubKey` for the aggregate public key. - pub script_pubkey: Bytes, + pub script_pubkey: ScriptPubKey, /// The encrypted DKG shares pub encrypted_private_shares: Bytes, /// The public DKG shares diff --git a/signer/src/storage/postgres.rs b/signer/src/storage/postgres.rs index e4e8f3e9..8be72eff 100644 --- a/signer/src/storage/postgres.rs +++ b/signer/src/storage/postgres.rs @@ -441,7 +441,7 @@ impl super::DbRead for PgStore { "#, ) .bind(chain_tip) - .bind(context_window as i32) + .bind(i32::from(context_window)) .fetch_all(&self.0) .await .map_err(Error::SqlxQuery) @@ -502,8 +502,8 @@ impl super::DbRead for PgStore { "#, ) .bind(chain_tip) - .bind(context_window as i32) - .bind(threshold as i32) + .bind(i32::from(context_window)) + .bind(i32::from(threshold)) .fetch_all(&self.0) .await .map_err(Error::SqlxQuery) @@ -546,7 +546,7 @@ impl super::DbRead for PgStore { ) .bind(aggregate_key) .bind(txid) - .bind(output_index as i64) + .bind(i64::from(output_index)) .fetch_all(&self.0) .await .map(model::SignerVotes::from) @@ -740,7 +740,7 @@ impl super::DbRead for PgStore { ) .bind(chain_tip) .bind(stacks_chain_tip.block_hash) - .bind(context_window as i32) + .bind(i32::from(context_window)) .fetch_all(&self.0) .await .map_err(Error::SqlxQuery) @@ -828,8 +828,8 @@ impl super::DbRead for PgStore { ) .bind(chain_tip) .bind(stacks_chain_tip.block_hash) - .bind(context_window as i32) - .bind(threshold as i64) + .bind(i32::from(context_window)) + .bind(i64::from(threshold)) .fetch_all(&self.0) .await .map_err(Error::SqlxQuery) @@ -946,6 +946,17 @@ impl super::DbRead for PgStore { async fn get_signers_script_pubkeys(&self) -> Result, Error> { sqlx::query_scalar::<_, model::Bytes>( r#" + WITH last_script_pubkey AS ( + SELECT script_pubkey + FROM sbtc_signer.dkg_shares + ORDER BY created_at DESC + LIMIT 1 + ) + SELECT script_pubkey + FROM last_script_pubkey + + UNION + SELECT script_pubkey FROM sbtc_signer.dkg_shares WHERE created_at > CURRENT_TIMESTAMP - INTERVAL '365 DAYS'; @@ -1061,32 +1072,52 @@ impl super::DbRead for PgStore { WITH RECURSIVE tx_block_chain AS ( SELECT block_hash + , block_height , parent_hash , 0 AS counter FROM sbtc_signer.bitcoin_blocks - WHERE block_hash = $2 + WHERE block_hash = $1 UNION ALL SELECT child.block_hash + , child.block_height , child.parent_hash , parent.counter + 1 FROM sbtc_signer.bitcoin_blocks AS child JOIN tx_block_chain AS parent - ON child.parent_hash = parent.block_hash + ON child.block_hash = parent.parent_hash WHERE parent.counter <= $3 ) SELECT EXISTS ( SELECT TRUE FROM tx_block_chain AS tbc - WHERE tbc.block_hash = $1 + WHERE tbc.block_hash = $2 + AND tbc.block_height = $4 ); "#, ) .bind(chain_tip.block_hash) .bind(block_ref.block_hash) - .bind(heigh_diff as i64) + .bind(i64::try_from(heigh_diff).map_err(Error::ConversionDatabaseInt)?) + .bind(i64::try_from(block_ref.block_height).map_err(Error::ConversionDatabaseInt)?) + .fetch_one(&self.0) + .await + .map_err(Error::SqlxQuery) + } + + async fn is_signer_script_pub_key(&self, script: &model::ScriptPubKey) -> Result { + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT TRUE + FROM sbtc_signer.dkg_shares AS ds + WHERE ds.script_pubkey = $1 + ); + "#, + ) + .bind(script) .fetch_one(&self.0) .await .map_err(Error::SqlxQuery) @@ -1176,7 +1207,7 @@ impl super::DbWrite for PgStore { ON CONFLICT DO NOTHING", ) .bind(deposit_request.txid) - .bind(deposit_request.output_index as i32) + .bind(i32::try_from(deposit_request.output_index).map_err(Error::ConversionDatabaseInt)?) .bind(&deposit_request.spend_script) .bind(&deposit_request.reclaim_script) .bind(&deposit_request.recipient) @@ -1208,8 +1239,9 @@ impl super::DbWrite for PgStore { let mut sender_script_pubkeys = Vec::with_capacity(deposit_requests.len()); for req in deposit_requests { + let vout = i32::try_from(req.output_index).map_err(Error::ConversionDatabaseInt)?; txid.push(req.txid); - output_index.push(req.output_index as i32); + output_index.push(vout); spend_script.push(req.spend_script); reclaim_script.push(req.reclaim_script); recipient.push(req.recipient); @@ -1328,7 +1360,7 @@ impl super::DbWrite for PgStore { ON CONFLICT DO NOTHING", ) .bind(decision.txid) - .bind(decision.output_index as i32) + .bind(i32::try_from(decision.output_index).map_err(Error::ConversionDatabaseInt)?) .bind(decision.signer_pub_key) .bind(decision.is_accepted) .execute(&self.0) @@ -1583,7 +1615,7 @@ impl super::DbWrite for PgStore { .bind(key_rotation.txid) .bind(key_rotation.aggregate_key) .bind(&key_rotation.signer_set) - .bind(key_rotation.signatures_required as i32) + .bind(i32::from(key_rotation.signatures_required)) .execute(&self.0) .await .map_err(Error::SqlxQuery)?; @@ -1610,7 +1642,7 @@ impl super::DbWrite for PgStore { .bind(event.block_id.0) .bind(i64::try_from(event.amount).map_err(Error::ConversionDatabaseInt)?) .bind(event.outpoint.txid.to_byte_array()) - .bind(event.outpoint.vout as i64) + .bind(i64::from(event.outpoint.vout)) .execute(&self.0) .await .map_err(Error::SqlxQuery)?; @@ -1673,7 +1705,7 @@ impl super::DbWrite for PgStore { .bind(i64::try_from(event.request_id).map_err(Error::ConversionDatabaseInt)?) .bind(event.signer_bitmap.into_inner()) .bind(event.outpoint.txid.to_byte_array()) - .bind(event.outpoint.vout as i64) + .bind(i64::from(event.outpoint.vout)) .bind(i64::try_from(event.fee).map_err(Error::ConversionDatabaseInt)?) .execute(&self.0) .await diff --git a/signer/src/testing/dummy.rs b/signer/src/testing/dummy.rs index 1dba1a1c..547dabcd 100644 --- a/signer/src/testing/dummy.rs +++ b/signer/src/testing/dummy.rs @@ -227,7 +227,7 @@ pub fn encrypted_dkg_shares( encrypted_private_shares, public_shares, tweaked_aggregate_key: group_key.signers_tweaked_pubkey().unwrap(), - script_pubkey: group_key.signers_script_pubkey().into_bytes(), + script_pubkey: group_key.signers_script_pubkey().into(), } } diff --git a/signer/src/testing/wallet.rs b/signer/src/testing/wallet.rs index 8d4d53bc..7a6619ef 100644 --- a/signer/src/testing/wallet.rs +++ b/signer/src/testing/wallet.rs @@ -12,12 +12,15 @@ use clarity::vm::ClarityName; use clarity::vm::Value as ClarityValue; use rand::rngs::StdRng; use rand::SeedableRng as _; +use sbtc::testing::regtest::Recipient; use secp256k1::Keypair; use stacks_common::types::chainstate::StacksAddress; use crate::config::NetworkKind; use crate::context::Context; use crate::error::Error; +use crate::keys::PrivateKey; +use crate::keys::PublicKey; use crate::stacks::contracts::AsContractCall; use crate::stacks::contracts::AsTxPayload; use crate::stacks::contracts::ReqContext; @@ -47,6 +50,34 @@ pub fn generate_wallet() -> (SignerWallet, [Keypair; 3], u16) { (wallet, key_pairs, signatures_required) } +/// This function creates a signing set where the aggregate key is the +/// given controller's public key. +pub fn create_signers_keys(rng: &mut R, signer: &Recipient, num_signers: usize) -> Vec +where + R: rand::Rng, +{ + // We only take an odd number of signers so that the math works out. + assert_eq!(num_signers % 2, 1); + + let private_key = PrivateKey::from(signer.keypair.secret_key()); + let aggregate_key = PublicKey::from_private_key(&private_key); + // The private keys of half of the other signers + let pks: Vec = std::iter::repeat_with(|| secp256k1::SecretKey::new(rng)) + .take(num_signers / 2) + .collect(); + + let mut keys: Vec = pks + .clone() + .into_iter() + .chain(pks.into_iter().map(secp256k1::SecretKey::negate)) + .map(|sk| PublicKey::from_private_key(&PrivateKey::from(sk))) + .chain([aggregate_key]) + .collect(); + + keys.sort(); + keys +} + /// A generic new-type that implements [`AsTxPayload`] for all types that /// implement [`AsContractCall`]. /// @@ -161,3 +192,25 @@ impl AsContractCall for InitiateWithdrawalRequest { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + use rand::rngs::OsRng; + use test_case::test_case; + + #[test_case(3; "3 signers")] + #[test_case(5; "5 signers")] + #[test_case(7; "7 signers")] + #[test_case(15; "15 signers")] + fn constructed_signer_set_has_desired_aggregate_key(num_signers: usize) { + let signer = Recipient::new(bitcoin::AddressType::P2tr); + + let aggregate_key = PublicKey::from(signer.keypair.public_key()); + let keys = create_signers_keys(&mut OsRng, &signer, num_signers); + + assert_eq!(keys.len(), num_signers); + assert_eq!(PublicKey::combine_keys(&keys).unwrap(), aggregate_key); + } +} diff --git a/signer/src/transaction_signer.rs b/signer/src/transaction_signer.rs index 0e3b78d6..b57614ef 100644 --- a/signer/src/transaction_signer.rs +++ b/signer/src/transaction_signer.rs @@ -629,7 +629,7 @@ where &mut self, withdraw_request: model::WithdrawalRequest, ) -> Result<(), Error> { - // TODO: Do we want to do this on the sender address of the + // TODO: Do we want to do this on the sender address or the // recipient address? let is_accepted = self .can_accept(&withdraw_request.sender_address.to_string()) diff --git a/signer/src/wsts_state_machine.rs b/signer/src/wsts_state_machine.rs index 1cb483e8..53bcb417 100644 --- a/signer/src/wsts_state_machine.rs +++ b/signer/src/wsts_state_machine.rs @@ -135,7 +135,7 @@ impl SignerStateMachine { Ok(model::EncryptedDkgShares { aggregate_key, tweaked_aggregate_key: aggregate_key.signers_tweaked_pubkey()?, - script_pubkey: aggregate_key.signers_script_pubkey().to_bytes(), + script_pubkey: aggregate_key.signers_script_pubkey().into(), encrypted_private_shares, public_shares, }) diff --git a/signer/tests/integration/complete_deposit.rs b/signer/tests/integration/complete_deposit.rs index 8e7e54fd..5937caff 100644 --- a/signer/tests/integration/complete_deposit.rs +++ b/signer/tests/integration/complete_deposit.rs @@ -1,77 +1,75 @@ use std::sync::atomic::Ordering; -use bitcoin::hashes::Hash as _; -use bitcoin::OutPoint; use blockstack_lib::types::chainstate::StacksAddress; - use rand::rngs::OsRng; +use rand::SeedableRng; + +use sbtc::testing::regtest; use signer::error::Error; -use signer::keys::PublicKey; use signer::stacks::contracts::AsContractCall as _; use signer::stacks::contracts::CompleteDepositV1; use signer::stacks::contracts::DepositErrorMsg; use signer::stacks::contracts::ReqContext; -use signer::storage::model; -use signer::storage::model::BitcoinBlock; -use signer::storage::model::BitcoinTxRef; -use signer::storage::model::DepositRequest; +use signer::storage::model::BitcoinBlockRef; use signer::storage::model::StacksPrincipal; -use signer::storage::postgres::PgStore; -use signer::storage::DbRead as _; -use signer::storage::DbWrite as _; use signer::testing; -use signer::testing::dummy::SweepTxConfig; -use signer::testing::storage::model::TestData; +use signer::testing::TestSignerContext; use fake::Fake; -use rand::SeedableRng; -use signer::testing::TestSignerContext; +use crate::setup::backfill_bitcoin_blocks; +use crate::setup::TestSweepSetup; use crate::DATABASE_NUM; /// Create a "proper" [`CompleteDepositV1`] object and context with the /// given information. If the information here is correct then the returned /// [`CompleteDepositV1`] object will pass validation with the given /// context. -fn make_complete_deposit( - req: &DepositRequest, - sweep_tx: &model::Transaction, - chain_tip: &BitcoinBlock, -) -> (CompleteDepositV1, ReqContext) { - // Okay now we get ready to create the transaction using the - // `CompleteDepositV1` type. +fn make_complete_deposit(data: &TestSweepSetup) -> (CompleteDepositV1, ReqContext) { + // The fee assessed for a deposit is subtracted from the minted amount. + let fee = data + .sweep_tx_info + .assess_input_fee(&data.deposit_request.outpoint) + .unwrap() + .to_sat(); let complete_deposit_tx = CompleteDepositV1 { // This OutPoint points to the deposit UTXO. - outpoint: req.outpoint(), + outpoint: data.deposit_request.outpoint, // This amount must not exceed the amount in the deposit request. - amount: req.amount, + amount: data.deposit_request.amount - fee, // The recipient must match what was indicated in the deposit // request. - recipient: req.recipient.clone().into(), + recipient: data.deposit_recipient.clone(), // The deployer must match what is in the signers' context. deployer: StacksAddress::burn_address(false), // The sweep transaction ID must point to a transaction on // the canonical bitcoin blockchain. - sweep_txid: sweep_tx.txid.into(), + sweep_txid: data.sweep_tx_info.txid.into(), // The block hash of the block that includes the above sweep // transaction. It must be on the canonical bitcoin blockchain. - sweep_block_hash: chain_tip.block_hash, + sweep_block_hash: data.sweep_block_hash.into(), // This must be the height of the above block. - sweep_block_height: chain_tip.block_height, + sweep_block_height: data.sweep_block_height, }; - // This is what the current signer things of the state of things. + // This is what the current signer thinks is the state of things. let req_ctx = ReqContext { - chain_tip: chain_tip.into(), + chain_tip: BitcoinBlockRef { + block_hash: data.sweep_block_hash.into(), + block_height: data.sweep_block_height, + }, // This value means that the signer will go back 10 blocks when // looking for pending and accepted deposit requests. context_window: 10, // The value here doesn't matter. origin: fake::Faker.fake_with_rng(&mut OsRng), - // This value doesn't matter here. - aggregate_key: fake::Faker.fake_with_rng(&mut OsRng), + // When checking whether the transaction is from the signer, we + // check that the first "prevout" has a `scriptPubKey` that the + // signers control. + aggregate_key: data.aggregated_signer.keypair.public_key().into(), // This value affects how many deposit transactions are consider - // accepted. + // accepted. During validation, a signer won't sign a transaction + // if it is not considered accepted but the collection of signers. signatures_required: 2, // This is who the current signer thinks deployed the sBTC // contracts. @@ -81,119 +79,54 @@ fn make_complete_deposit( (complete_deposit_tx, req_ctx) } -/// Generate a signer set, deposit requests and store them into the -/// database. -async fn deposit_setup(rng: &mut R, db: &PgStore) -> Vec -where - R: rand::RngCore + rand::CryptoRng, -{ - // This is just a sql test, where we use the `TestData` struct to help - // populate the database with test data. We set all the other - // unnecessary parameters to zero. - let num_signers = 7; - let test_model_params = testing::storage::model::Params { - num_bitcoin_blocks: 20, - num_stacks_blocks_per_bitcoin_block: 0, - num_deposit_requests_per_block: 2, - num_withdraw_requests_per_block: 0, - num_signers_per_request: num_signers, - }; - - // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. - let signer_set = testing::wsts::generate_signer_set_public_keys(rng, num_signers); - let test_data = TestData::generate(rng, &signer_set, &test_model_params); - test_data.write_to(db).await; - signer_set -} - -/// Get the full block -async fn get_bitcoin_canonical_chain_tip_block(db: &PgStore) -> BitcoinBlock { - sqlx::query_as::<_, BitcoinBlock>( - "SELECT - block_hash - , block_height - , parent_hash - , confirms - FROM sbtc_signer.bitcoin_blocks - ORDER BY block_height DESC, block_hash DESC - LIMIT 1", - ) - .fetch_optional(db.pool()) - .await - .unwrap() - .unwrap() -} - -/// Get an existing deposit request that has been confirmed on the -/// canonical bitcoin blockchain. -/// -/// The signatures required field affects which deposit requests are -/// eligible for being accepted. In these tests, we just need any old -/// deposit request so this value doesn't matter so long as we get one -/// deposit request that meets these requirements. -async fn get_pending_accepted_deposit_requests( - db: &PgStore, - chain_tip: &BitcoinBlock, - signatures_required: u16, -) -> DepositRequest { - // The context window limits how far back we look in the blockchain for - // accepted and pending deposit requests. For these tests, this value - // is fine. - db.get_pending_accepted_deposit_requests(&chain_tip.block_hash, 20, signatures_required) - .await - .unwrap() - .last() - .cloned() - .unwrap() -} - -/// For this test we check that the `CompleteDepositV1::validate` function -/// returns okay when everything matches the way that it is supposed to. #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn complete_deposit_validation_happy_path() { // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. + // transactions and a transaction sweeping in the deposited funds. + // This is just setup and should be essentially the same between tests. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 2; - - let signer_set = deposit_setup(&mut rng, &db).await; - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing deposit request on the canonical bitcoin - // blockchain. - let deposit_req = - get_pending_accepted_deposit_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps in the deposit. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: vec![deposit_req.outpoint()], - outputs: Vec::new(), - }; - let mut sweep_tx: model::Transaction = sweep_config.fake_with_rng(&mut rng); - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - sweep_tx.block_hash = chain_tip.block_hash.into_bytes(); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); - - // Generate the transaction and corresponding request context. - let (complete_deposit_tx, req_ctx) = make_complete_deposit(&deposit_req, &sweep_tx, &chain_tip); - - // This should not return an Err. + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (complete_deposit_tx, req_ctx) = make_complete_deposit(&setup); + + // Create a context object for reaching out to the database and bitcoin + // core. This will create a bitcoin core client that connects to the + // bitcoin-core at the [bitcoin].endpoints[0] endpoint from the default + // toml config file. let ctx = TestSignerContext::from_db(db.clone()); + // Check to see if validation passes. complete_deposit_tx.validate(&ctx, &req_ctx).await.unwrap(); testing::storage::drop_db(db).await; @@ -206,47 +139,45 @@ async fn complete_deposit_validation_happy_path() { #[tokio::test] async fn complete_deposit_validation_deployer_mismatch() { // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. + // transactions and a transaction sweeping in the deposited funds. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 2; - - let signer_set = deposit_setup(&mut rng, &db).await; - - // Get the chain tip - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing deposit request on the canonical bitcoin - // blockchain. - let deposit_req = - get_pending_accepted_deposit_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps in the deposit. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: vec![deposit_req.outpoint()], - outputs: Vec::new(), - }; - let mut sweep_tx: model::Transaction = sweep_config.fake_with_rng(&mut rng); - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - sweep_tx.block_hash = chain_tip.block_hash.into_bytes(); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); - - // Generate the transaction and corresponding request context. - let (mut complete_deposit_tx, mut req_ctx) = - make_complete_deposit(&deposit_req, &sweep_tx, &chain_tip); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (mut complete_deposit_tx, mut req_ctx) = make_complete_deposit(&setup); // Different: Okay, let's make sure we get the deployers do not match. - complete_deposit_tx.deployer = StacksAddress::p2pkh(false, &signer_set[0].into()); - req_ctx.deployer = StacksAddress::p2pkh(false, &signer_set[1].into()); + complete_deposit_tx.deployer = StacksAddress::p2pkh(false, &setup.signer_keys[0].into()); + req_ctx.deployer = StacksAddress::p2pkh(false, &setup.signer_keys[1].into()); + let ctx = TestSignerContext::from_db(db.clone()); let validate_future = complete_deposit_tx.validate(&ctx, &req_ctx); @@ -268,41 +199,39 @@ async fn complete_deposit_validation_deployer_mismatch() { #[tokio::test] async fn complete_deposit_validation_missing_deposit_request() { // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. + // transactions and a transaction sweeping in the deposited funds. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); - let signer_set = deposit_setup(&mut rng, &db).await; - - // Normal: Get the chain tip and any pending deposit request in the blockchain - // identified by the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Different: Let's use a random deposit request instead of one that - // exists in the database. - let deposit_req: DepositRequest = fake::Faker.fake_with_rng(&mut rng); - - // Normal: we generate a transaction that sweeps in the deposit. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: vec![deposit_req.outpoint()], - outputs: Vec::new(), - }; - let mut sweep_tx: model::Transaction = sweep_config.fake_with_rng(&mut rng); - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - sweep_tx.block_hash = chain_tip.block_hash.into_bytes(); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Different: We do not store the deposit request and the associated + // decisions in the database. + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (complete_deposit_tx, req_ctx) = make_complete_deposit(&setup); - let (complete_deposit_tx, req_ctx) = make_complete_deposit(&deposit_req, &sweep_tx, &chain_tip); let ctx = TestSignerContext::from_db(db.clone()); let validation_result = complete_deposit_tx.validate(&ctx, &req_ctx).await; @@ -324,44 +253,41 @@ async fn complete_deposit_validation_missing_deposit_request() { #[tokio::test] async fn complete_deposit_validation_recipient_mismatch() { // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. + // transactions and a transaction sweeping in the deposited funds. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 2; - - let signer_set = deposit_setup(&mut rng, &db).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing deposit request on the canonical bitcoin - // blockchain. - let deposit_req = - get_pending_accepted_deposit_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps in the deposit. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: vec![deposit_req.outpoint()], - outputs: Vec::new(), - }; - let mut sweep_tx: model::Transaction = sweep_config.fake_with_rng(&mut rng); - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - sweep_tx.block_hash = chain_tip.block_hash.into_bytes(); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); - - // Generate the transaction and corresponding request context. - let (mut complete_deposit_tx, req_ctx) = - make_complete_deposit(&deposit_req, &sweep_tx, &chain_tip); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (mut complete_deposit_tx, req_ctx) = make_complete_deposit(&setup); // Different: Okay, let's make sure we the recipients do not match. complete_deposit_tx.recipient = fake::Faker .fake_with_rng::(&mut rng) @@ -387,47 +313,44 @@ async fn complete_deposit_validation_recipient_mismatch() { #[tokio::test] async fn complete_deposit_validation_invalid_mint_amount() { // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. + // transactions and a transaction sweeping in the deposited funds. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 2; - - let signer_set = deposit_setup(&mut rng, &db).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing deposit request on the canonical bitcoin - // blockchain. - let deposit_req = - get_pending_accepted_deposit_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps in the deposit. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: vec![deposit_req.outpoint()], - outputs: Vec::new(), - }; - let mut sweep_tx: model::Transaction = sweep_config.fake_with_rng(&mut rng); - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - sweep_tx.block_hash = chain_tip.block_hash.into_bytes(); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); - - // Generate the transaction and corresponding request context. - let (mut complete_deposit_tx, req_ctx) = - make_complete_deposit(&deposit_req, &sweep_tx, &chain_tip); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (mut complete_deposit_tx, req_ctx) = make_complete_deposit(&setup); // Different: The amount cannot exceed the amount in the deposit // request. - complete_deposit_tx.amount = deposit_req.amount + 1; + complete_deposit_tx.amount = setup.deposit_request.amount + 1; let ctx = TestSignerContext::from_db(db.clone()); let validate_future = complete_deposit_tx.validate(&ctx, &req_ctx); @@ -442,60 +365,63 @@ async fn complete_deposit_validation_invalid_mint_amount() { } /// For this test we check that the `CompleteDepositV1::validate` function -/// returns a deposit validation error with a InvalidFee message when the +/// returns a deposit validation error with a FeeTooHigh message when the /// amount of sBTC to mint is less than the `amount - max-fee` from in the /// signer's deposit request record. #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] -async fn complete_deposit_validation_invalid_fee() { +async fn complete_deposit_validation_fee_too_high() { // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. + // transactions and a transaction sweeping in the deposited funds. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 2; - - let signer_set = deposit_setup(&mut rng, &db).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing deposit request on the canonical bitcoin - // blockchain. - let deposit_req = - get_pending_accepted_deposit_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps in the deposit. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: vec![deposit_req.outpoint()], - outputs: Vec::new(), - }; - let mut sweep_tx: model::Transaction = sweep_config.fake_with_rng(&mut rng); - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - sweep_tx.block_hash = chain_tip.block_hash.into_bytes(); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); - - // Generate the transaction and corresponding request context. - let (mut complete_deposit_tx, req_ctx) = - make_complete_deposit(&deposit_req, &sweep_tx, &chain_tip); - // Different: The amount cannot be less than the deposit amount less - // the max-fee. - complete_deposit_tx.amount = deposit_req.amount - deposit_req.max_fee - 1; + let (rpc, faucet) = regtest::initialize_blockchain(); + let mut setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Different: the actual assessed fee cannot be greater than the + // max-fee, so here we adjust the max fee to pretend what would happen + // during validation if assessed transaction fee exceeded that amount. + let assessed_fee = setup + .sweep_tx_info + .assess_input_fee(&setup.deposit_request.outpoint); + setup.deposit_request.max_fee = assessed_fee.unwrap().to_sat() - 1; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (complete_deposit_tx, req_ctx) = make_complete_deposit(&setup); + let ctx = TestSignerContext::from_db(db.clone()); let validate_future = complete_deposit_tx.validate(&ctx, &req_ctx); match validate_future.await.unwrap_err() { Error::DepositValidation(ref err) => { - assert_eq!(err.error, DepositErrorMsg::InvalidFee) + assert_eq!(err.error, DepositErrorMsg::FeeTooHigh) } err => panic!("unexpected error during validation {err}"), } @@ -511,38 +437,47 @@ async fn complete_deposit_validation_invalid_fee() { #[tokio::test] async fn complete_deposit_validation_sweep_tx_missing() { // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. + // transactions and a transaction sweeping in the deposited funds. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 2; - - let signer_set = deposit_setup(&mut rng, &db).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing deposit request on the canonical bitcoin - // blockchain. - let deposit_req = - get_pending_accepted_deposit_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps in the deposit. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: vec![deposit_req.outpoint()], - outputs: Vec::new(), - }; - let mut sweep_tx: model::Transaction = sweep_config.fake_with_rng(&mut rng); - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - sweep_tx.block_hash = chain_tip.block_hash.into_bytes(); - - // Different: we are supposed to store a sweep transaction, but we do - // not do that here. + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (mut complete_deposit_tx, req_ctx) = make_complete_deposit(&setup); + + // Different: there is supposed to be sweep transaction in + // bitcoin-core, but we make sure that such a transaction does not + // exist. + complete_deposit_tx.sweep_txid = fake::Faker.fake_with_rng(&mut rng); - // Generate the transaction and corresponding request context. - let (complete_deposit_tx, req_ctx) = make_complete_deposit(&deposit_req, &sweep_tx, &chain_tip); let ctx = TestSignerContext::from_db(db.clone()); let validation_result = complete_deposit_tx.validate(&ctx, &req_ctx).await; @@ -564,63 +499,56 @@ async fn complete_deposit_validation_sweep_tx_missing() { #[tokio::test] async fn complete_deposit_validation_sweep_reorged() { // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. + // transactions and a transaction sweeping in the deposited funds. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 2; - - let signer_set = deposit_setup(&mut rng, &db).await; - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing deposit request on the canonical bitcoin - // blockchain. - let deposit_req = - get_pending_accepted_deposit_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps in the deposit. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: vec![deposit_req.outpoint()], - outputs: Vec::new(), + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (complete_deposit_tx, mut req_ctx) = make_complete_deposit(&setup); + + // Different: the transaction that sweeps in the deposit has been + // confirmed, but let's suppose that it gets confirmed on a bitcoin + // blockchain that is not the canonical one. To test that we set a + // chain tip to be some other blockchain. The important part is that + // our sweep transaction is not on the canonical one. + req_ctx.chain_tip = BitcoinBlockRef { + block_hash: fake::Faker.fake_with_rng(&mut rng), + // This value kind of matters, but that's more of an implementation + // detail. All that should matter is that the block_hash does not + // identify the bitcoin blockchain that includes the sweep + // transaction. + block_height: 30000, }; - let mut sweep_tx: model::Transaction = sweep_config.fake_with_rng(&mut rng); - // Different: the transaction that sweeps in the deposit gets - // confirmed, but on a bitcoin blockchain that is not the canonical - // one. We generate a new blockchain and put it there. - // - // Note that this blockchain might actually have a greater height, - // but we get to say which one is the canonical one in our context so - // that fact doesn't matter in this test. - let test_model_params = testing::storage::model::Params { - num_bitcoin_blocks: 10, - num_stacks_blocks_per_bitcoin_block: 0, - num_deposit_requests_per_block: 0, - num_withdraw_requests_per_block: 0, - num_signers_per_request: 0, - }; - let test_data2 = TestData::generate(&mut rng, &signer_set, &test_model_params); - test_data2.write_to(&db).await; - let chain_tip2 = test_data2 - .bitcoin_blocks - .iter() - .max_by_key(|x| (x.block_height, x.block_hash)) - .unwrap(); - sweep_tx.block_hash = chain_tip2.block_hash.into_bytes(); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); - // Generate the transaction and corresponding request context. - let (complete_deposit_tx, mut req_ctx) = - make_complete_deposit(&deposit_req, &sweep_tx, &chain_tip2); - req_ctx.chain_tip = chain_tip.into(); let ctx = TestSignerContext::from_db(db.clone()); let validation_result = complete_deposit_tx.validate(&ctx, &req_ctx).await; @@ -635,7 +563,7 @@ async fn complete_deposit_validation_sweep_reorged() { } /// For this test we check that the `CompleteDepositV1::validate` function -/// returns a deposit validation error with a DepositMissingFromSweep +/// returns a deposit validation error with a MissingFromSweep /// message when the sweep transaction is in our records, is on what the /// signer thinks is the canonical bitcoin blockchain, but it does not have /// an input that that matches the deposit request outpoint. @@ -643,47 +571,48 @@ async fn complete_deposit_validation_sweep_reorged() { #[tokio::test] async fn complete_deposit_validation_deposit_not_in_sweep() { // Normal: this generates the blockchain as well as deposit request - // transactions in each bitcoin block. + // transactions and a transaction sweeping in the deposited funds. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 2; - - let signer_set = deposit_setup(&mut rng, &db).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing deposit request on the canonical bitcoin - // blockchain. - let deposit_req = - get_pending_accepted_deposit_requests(&db, &chain_tip, signatures_required).await; - - // Different: The sweep transaction does not include the deposit - // request UTXO as an input. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: vec![OutPoint { - txid: bitcoin::Txid::from_byte_array(fake::Faker.fake_with_rng(&mut rng)), - vout: 0, - }], - outputs: Vec::new(), - }; - let mut sweep_tx: model::Transaction = sweep_config.fake_with_rng(&mut rng); - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - sweep_tx.block_hash = chain_tip.block_hash.into_bytes(); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (mut complete_deposit_tx, req_ctx) = make_complete_deposit(&setup); + + // Different: We want to simulate what would happen if the sweep + // transaction did not include the deposit request UTXO as an input. To + // do that we set the outpoint of the deposit to be different from any + // of the prevout outpoints in the sweep transaction. + complete_deposit_tx.outpoint.vout = 5000; - // Generate the transaction and corresponding request context. - let (complete_deposit_tx, req_ctx) = make_complete_deposit(&deposit_req, &sweep_tx, &chain_tip); let ctx = TestSignerContext::from_db(db.clone()); let validation_result = complete_deposit_tx.validate(&ctx, &req_ctx).await; @@ -696,3 +625,123 @@ async fn complete_deposit_validation_deposit_not_in_sweep() { testing::storage::drop_db(db).await; } + +/// For this test we check that the `CompleteDepositV1::validate` function +/// returns a deposit validation error with a IncorrectFee message when the +/// sweep transaction is in our records, is on what the signer thinks is +/// the canonical bitcoin blockchain, but the fee assessed differs from +/// what we would expect. +#[cfg_attr(not(feature = "integration-tests"), ignore)] +#[tokio::test] +async fn complete_deposit_validation_deposit_incorrect_fee() { + // Normal: this generates the blockchain as well as deposit request + // transactions and a transaction sweeping in the deposited funds. + let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); + let db = testing::storage::new_test_database(db_num, true).await; + let mut rng = rand::rngs::StdRng::seed_from_u64(51); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (mut complete_deposit_tx, req_ctx) = make_complete_deposit(&setup); + // Different: the amount here is less than we would think that it + // should be, implying that the assessed fee is greater than what we + // would have thought. + complete_deposit_tx.amount -= 1; + + let ctx = TestSignerContext::from_db(db.clone()); + + let validation_result = complete_deposit_tx.validate(&ctx, &req_ctx).await; + match validation_result.unwrap_err() { + Error::DepositValidation(ref err) => { + assert_eq!(err.error, DepositErrorMsg::IncorrectFee) + } + err => panic!("unexpected error during validation {err}"), + } + + testing::storage::drop_db(db).await; +} + +/// For this test we check that the `CompleteDepositV1::validate` function +/// returns a deposit validation error with a InvalidSweep message when the +/// sweep transaction does not have a prevout with a scriptPubKey that the +/// signers control. +#[cfg_attr(not(feature = "integration-tests"), ignore)] +#[tokio::test] +async fn complete_deposit_validation_deposit_invalid_sweep() { + // Normal: this generates the blockchain as well as deposit request + // transactions and a transaction sweeping in the deposited funds. + let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); + let db = testing::storage::new_test_database(db_num, true).await; + let mut rng = rand::rngs::StdRng::seed_from_u64(51); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the deposit transaction as is from the test setup + // and store it in the database. This is necessary for when we fetch + // outstanding unfulfilled deposit requests. + setup.store_deposit_tx(&db).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Different: we normally add a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. Here we + // exclude it, so it looks like the first UTXO in the transaction is not + // controlled by the signers. + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the deposit request object + // corresponds to how the signers voted. + setup.store_deposit_request(&db).await; + setup.store_deposit_decisions(&db).await; + + // Normal: create a properly formed complete-deposit transaction object + // and the corresponding request context. + let (complete_deposit_tx, req_ctx) = make_complete_deposit(&setup); + + let ctx = TestSignerContext::from_db(db.clone()); + + let validation_result = complete_deposit_tx.validate(&ctx, &req_ctx).await; + match validation_result.unwrap_err() { + Error::DepositValidation(ref err) => { + assert_eq!(err.error, DepositErrorMsg::InvalidSweep) + } + err => panic!("unexpected error during validation {err}"), + } + + testing::storage::drop_db(db).await; +} diff --git a/signer/tests/integration/main.rs b/signer/tests/integration/main.rs index a77e5cb3..ffc705f4 100644 --- a/signer/tests/integration/main.rs +++ b/signer/tests/integration/main.rs @@ -6,6 +6,7 @@ mod complete_deposit; mod contracts; mod postgres; mod rbf; +mod setup; mod transaction_signer; mod utxo_construction; mod withdrawal_accept; diff --git a/signer/tests/integration/postgres.rs b/signer/tests/integration/postgres.rs index 606ac963..95379d8d 100644 --- a/signer/tests/integration/postgres.rs +++ b/signer/tests/integration/postgres.rs @@ -16,6 +16,7 @@ use signer::config::Settings; use signer::context::Context; use signer::error::Error; use signer::keys::PublicKey; +use signer::keys::SignerScriptPubKey as _; use signer::network; use signer::stacks::contracts::AcceptWithdrawalV1; use signer::stacks::contracts::AsContractCall; @@ -32,8 +33,10 @@ use signer::storage; use signer::storage::model; use signer::storage::model::BitcoinBlockHash; use signer::storage::model::BitcoinTxId; +use signer::storage::model::EncryptedDkgShares; use signer::storage::model::QualifiedRequestId; use signer::storage::model::RotateKeysTransaction; +use signer::storage::model::ScriptPubKey; use signer::storage::model::StacksBlock; use signer::storage::model::StacksBlockHash; use signer::storage::model::StacksTxId; @@ -1273,6 +1276,89 @@ async fn we_can_fetch_bitcoin_txs_from_db() { signer::testing::storage::drop_db(pg_store).await; } +/// Check that `is_signer_script_pub_key` correctly returns whether a +/// scriptPubKey value exists in the dkg_shares table. +#[cfg_attr(not(feature = "integration-tests"), ignore)] +#[tokio::test] +async fn is_signer_script_pub_key_checks_dkg_shares_for_script_pubkeys() { + let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); + let db = testing::storage::new_test_database(db_num, true).await; + let mem = storage::in_memory::Store::new_shared(); + + let mut rng = rand::rngs::StdRng::seed_from_u64(51); + + // Okay let's put a row in the dkg_shares table. + let aggregate_key: PublicKey = fake::Faker.fake_with_rng(&mut rng); + let script_pubkey: ScriptPubKey = aggregate_key.signers_script_pubkey().into(); + let shares = EncryptedDkgShares { + script_pubkey: script_pubkey.clone(), + tweaked_aggregate_key: aggregate_key.signers_tweaked_pubkey().unwrap(), + encrypted_private_shares: Vec::new(), + public_shares: Vec::new(), + aggregate_key, + }; + db.write_encrypted_dkg_shares(&shares).await.unwrap(); + mem.write_encrypted_dkg_shares(&shares).await.unwrap(); + + // Now we have a row in their with our scriptPubKey, let's make sure + // that the query accurately reports that. + assert!(db.is_signer_script_pub_key(&script_pubkey).await.unwrap()); + assert!(mem.is_signer_script_pub_key(&script_pubkey).await.unwrap()); + + // Now we try the case where it is the script pub key is missing from + // the database by generating a new one (well it's unlikely to be + // there). + let aggregate_key: PublicKey = fake::Faker.fake_with_rng(&mut rng); + let script_pubkey: ScriptPubKey = aggregate_key.signers_script_pubkey().into(); + + assert!(!db.is_signer_script_pub_key(&script_pubkey).await.unwrap()); + assert!(!mem.is_signer_script_pub_key(&script_pubkey).await.unwrap()); + + signer::testing::storage::drop_db(db).await; +} + +/// The [`DbRead::get_signers_script_pubkeys`] function is only supposed to +/// fetch the last 365 days worth of scriptPubKeys, but if there are no new +/// encrypted shares in the database in a year, we should still return the +/// most recent one. +#[cfg_attr(not(feature = "integration-tests"), ignore)] +#[tokio::test] +async fn get_signers_script_pubkeys_returns_non_empty_vec_old_rows() { + let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); + let db = testing::storage::new_test_database(db_num, true).await; + + let mut rng = rand::rngs::StdRng::seed_from_u64(51); + + let shares: model::EncryptedDkgShares = fake::Faker.fake_with_rng(&mut rng); + + sqlx::query( + r#" + INSERT INTO sbtc_signer.dkg_shares ( + aggregate_key + , tweaked_aggregate_key + , encrypted_private_shares + , public_shares + , script_pubkey + , created_at + ) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP - INTERVAL '366 DAYS') + ON CONFLICT DO NOTHING"#, + ) + .bind(shares.aggregate_key) + .bind(shares.tweaked_aggregate_key) + .bind(&shares.encrypted_private_shares) + .bind(&shares.public_shares) + .bind(&shares.script_pubkey) + .execute(db.pool()) + .await + .unwrap(); + + let keys = db.get_signers_script_pubkeys().await.unwrap(); + assert_eq!(keys.len(), 1); + + signer::testing::storage::drop_db(db).await; +} + async fn transaction_coordinator_test_environment( ) -> testing::transaction_coordinator::TestEnvironment> { diff --git a/signer/tests/integration/setup.rs b/signer/tests/integration/setup.rs new file mode 100644 index 00000000..3dc97a41 --- /dev/null +++ b/signer/tests/integration/setup.rs @@ -0,0 +1,349 @@ +use bitcoin::consensus::Encodable as _; +use bitcoin::hashes::Hash as _; +use bitcoin::AddressType; +use bitcoin::OutPoint; +use bitcoincore_rpc::Client; +use bitcoincore_rpc::RpcApi as _; +use blockstack_lib::types::chainstate::StacksAddress; +use clarity::vm::types::PrincipalData; + +use fake::Fake; +use fake::Faker; +use rand::rngs::OsRng; +use sbtc::testing::regtest; +use sbtc::testing::regtest::Faucet; +use sbtc::testing::regtest::Recipient; +use signer::bitcoin::rpc::BitcoinCoreClient; +use signer::bitcoin::rpc::BitcoinTxInfo; +use signer::bitcoin::utxo; +use signer::bitcoin::utxo::SbtcRequests; +use signer::bitcoin::utxo::SignerBtcState; +use signer::bitcoin::utxo::SignerUtxo; +use signer::config::Settings; +use signer::keys::PublicKey; +use signer::keys::SignerScriptPubKey; +use signer::storage::model; +use signer::storage::model::BitcoinTxRef; +use signer::storage::model::EncryptedDkgShares; +use signer::storage::postgres::PgStore; +use signer::storage::DbWrite as _; + +use crate::utxo_construction::generate_withdrawal; +use crate::utxo_construction::make_deposit_request; + +/// A struct containing an actual deposit and a sweep transaction. The +/// sweep transaction was signed with the `signer` field's public key. +pub struct TestSweepSetup { + /// The block hash of the bitcoin block that confirms the deposit + /// transaction. + pub deposit_block_hash: bitcoin::BlockHash, + /// Where the corresponding sBTC will be minted. + pub deposit_recipient: PrincipalData, + /// The deposit request, and a bitmap for how the singers voted on it. + pub deposit_request: utxo::DepositRequest, + /// The bitcoin transaction that the user made as a deposit for sBTC. + pub deposit_tx: bitcoin::Transaction, + /// The signer object. It's public key represents the group of signers' + /// public keys, allowing us to abstract away the fact that there are + /// many signers needed to sign a transaction. + pub aggregated_signer: Recipient, + /// The public keys of the signer set. It is effectively controlled by the above signer's private key. + pub signer_keys: Vec, + /// The block hash of the bitcoin block that confirmed the sweep + /// transaction. + pub sweep_block_hash: bitcoin::BlockHash, + /// The height of the bitcoin block that confirmed the sweep + /// transaction. + pub sweep_block_height: u64, + /// The transaction that swept in the deposit transaction. + pub sweep_tx_info: BitcoinTxInfo, + /// The withdrawal request, and a bitmap for how the signers voted on + /// it. + pub withdrawal_request: utxo::WithdrawalRequest, + /// The address that initiated with withdrawal request. + pub withdrawal_sender: PrincipalData, +} + +impl TestSweepSetup { + /// Construct a new TestSweepSetup + /// + /// This is done as follows: + /// 1. Generating a new "signer" and "depositor" objects that control + /// distinct private keys. + /// 2. The depositor constructs and confirms a proper deposit + /// transaction, with a burn address on stacks as the recipient. The + /// max fee is the entire deposit. + /// 3. Someone on the stacks network creates a withdrawal request to + /// sweep out funds. + /// 4. The signer sweeps in the deposited funds and sweeps out the + /// withdrawal funds in a proper sweep transaction, that is also + /// confirmed on bitcoin. + /// 5. Generate a set of "signer keys" that kinda represent the + /// signers. Transactions can be signed using only the private keys + /// of the "signer" from (1). + pub fn new_setup(rpc: &Client, faucet: &Faucet, amount: u64, rng: &mut R) -> Self + where + R: rand::Rng, + { + let signer = Recipient::new(AddressType::P2tr); + let depositor = Recipient::new(AddressType::P2tr); + let signers_public_key = signer.keypair.x_only_public_key().0; + + // Start off with some initial UTXOs to work with. + faucet.send_to(100_000_000, &signer.address); + faucet.send_to(50_000_000, &depositor.address); + faucet.generate_blocks(1); + + // Now lets make a deposit transaction and submit it + let depositor_utxo = depositor.get_utxos(rpc, None).pop().unwrap(); + + more_asserts::assert_lt!(amount, 50_000_000); + + let (deposit_tx, deposit_request) = + make_deposit_request(&depositor, amount, depositor_utxo, signers_public_key); + rpc.send_raw_transaction(&deposit_tx).unwrap(); + let deposit_block_hash = faucet.generate_blocks(1).pop().unwrap(); + + // This is randomly generated withdrawal request and the recipient + // who can sign for the withdrawal UTXO. + let (withdrawal_request, _withdrawal_recipient) = generate_withdrawal(); + // Okay now we try to peg-in the deposit by making a transaction. + // Let's start by getting the signer's sole UTXO. + let signer_utxo = signer.get_utxos(rpc, None).pop().unwrap(); + + let mut requests = SbtcRequests { + deposits: vec![deposit_request], + withdrawals: vec![withdrawal_request], + signer_state: SignerBtcState { + utxo: SignerUtxo { + outpoint: OutPoint::new(signer_utxo.txid, signer_utxo.vout), + amount: signer_utxo.amount.to_sat(), + public_key: signers_public_key, + }, + fee_rate: 10.0, + public_key: signers_public_key, + last_fees: None, + magic_bytes: [b'T', b'3'], + }, + accept_threshold: 4, + num_signers: 7, + }; + + // There should only be one transaction here since there is only + // one deposit request and no withdrawal requests. + let txid = { + let mut transactions = requests.construct_transactions().unwrap(); + assert_eq!(transactions.len(), 1); + let mut unsigned = transactions.pop().unwrap(); + + // Add the signature and/or other required information to the + // witness data. + signer::testing::set_witness_data(&mut unsigned, signer.keypair); + rpc.send_raw_transaction(&unsigned.tx).unwrap(); + unsigned.tx.compute_txid() + }; + + // Let's sweep in the transaction + let sweep_block_hash = faucet.generate_blocks(1).pop().unwrap(); + let sweep_block_height = + rpc.get_block_header_info(&sweep_block_hash).unwrap().height as u64; + + let settings = Settings::new_from_default_config().unwrap(); + let client = BitcoinCoreClient::try_from(&settings.bitcoin.rpc_endpoints[0]).unwrap(); + let sweep_tx_info = client + .get_tx_info(&txid, &sweep_block_hash) + .unwrap() + .unwrap(); + + TestSweepSetup { + deposit_block_hash, + deposit_recipient: PrincipalData::from(StacksAddress::burn_address(false)), + deposit_request: requests.deposits.pop().unwrap(), + deposit_tx, + sweep_tx_info, + sweep_block_height, + sweep_block_hash, + signer_keys: signer::testing::wallet::create_signers_keys(rng, &signer, 7), + aggregated_signer: signer, + withdrawal_request: requests.withdrawals.pop().unwrap(), + withdrawal_sender: PrincipalData::from(StacksAddress::burn_address(false)), + } + } + + /// Store the deposit transaction into the database + pub async fn store_deposit_tx(&self, db: &PgStore) { + let mut tx = Vec::new(); + self.deposit_tx.consensus_encode(&mut tx).unwrap(); + + let deposit_tx = model::Transaction { + tx, + txid: self.deposit_tx.compute_txid().to_byte_array(), + tx_type: model::TransactionType::SbtcTransaction, + block_hash: self.deposit_block_hash.to_byte_array(), + }; + + let bitcoin_tx_ref = BitcoinTxRef { + txid: deposit_tx.txid.into(), + block_hash: self.deposit_block_hash.into(), + }; + + db.write_transaction(&deposit_tx).await.unwrap(); + db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + } + /// Store the transaction that swept the deposit into the signers' UTXO + /// into the database + pub async fn store_sweep_tx(&self, db: &PgStore) { + let mut tx = Vec::new(); + self.sweep_tx_info.tx.consensus_encode(&mut tx).unwrap(); + + let sweep_tx = model::Transaction { + tx, + txid: self.sweep_tx_info.txid.to_byte_array(), + tx_type: model::TransactionType::SbtcTransaction, + block_hash: self.sweep_block_hash.to_byte_array(), + }; + + let bitcoin_tx_ref = BitcoinTxRef { + txid: sweep_tx.txid.into(), + block_hash: sweep_tx.block_hash.into(), + }; + + db.write_transaction(&sweep_tx).await.unwrap(); + db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + } + + /// Store the deposit request in the database. + pub async fn store_deposit_request(&self, db: &PgStore) { + let deposit_request = model::DepositRequest { + txid: self.deposit_request.outpoint.txid.into(), + output_index: self.deposit_request.outpoint.vout, + spend_script: self.deposit_request.deposit_script.clone().into(), + reclaim_script: self.deposit_request.reclaim_script.clone().into(), + recipient: self.deposit_recipient.clone().into(), + amount: self.deposit_request.amount, + max_fee: self.deposit_request.max_fee, + sender_script_pub_keys: Vec::new(), + }; + + db.write_deposit_request(&deposit_request).await.unwrap(); + } + + /// Store how the signers voted on the deposit request. + /// + /// The deposit request must be stored in the database before this + /// function is called. + /// + /// This function uses the `self.deposit_request.signer_bitmap` field + /// to generate the corresponding deposit signer votes and then stores + /// these decisions in the database. + pub async fn store_deposit_decisions(&self, db: &PgStore) { + let deposit_signers = self + .signer_keys + .iter() + .copied() + .zip(self.deposit_request.signer_bitmap) + .map(|(signer_pub_key, is_rejected)| model::DepositSigner { + txid: self.deposit_request.outpoint.txid.into(), + output_index: self.deposit_request.outpoint.vout, + signer_pub_key, + is_accepted: !is_rejected, + }); + + for decision in deposit_signers { + db.write_deposit_signer_decision(&decision).await.unwrap(); + } + } + + /// Use the bitmap in the `self.withdrawal_request.signer_bitmap` field to + /// generate the corresponding deposit signer votes and store these + /// decisions in the database. + pub async fn store_withdrawal_decisions(&self, db: &PgStore) { + let withdrawal_signers: Vec = self + .signer_keys + .iter() + .copied() + .zip(self.deposit_request.signer_bitmap) + .map(|(signer_pub_key, is_rejected)| model::WithdrawalSigner { + request_id: self.withdrawal_request.request_id, + block_hash: self.withdrawal_request.block_hash, + txid: self.withdrawal_request.txid, + signer_pub_key, + is_accepted: !is_rejected, + }) + .collect(); + + for decision in withdrawal_signers { + db.write_withdrawal_signer_decision(&decision) + .await + .unwrap(); + } + } + + pub async fn store_withdrawal_request(&self, db: &PgStore) { + let block = model::StacksBlock { + block_hash: self.withdrawal_request.block_hash, + block_height: self.sweep_block_height, + parent_hash: Faker.fake_with_rng(&mut OsRng), + }; + db.write_stacks_block(&block).await.unwrap(); + + sqlx::query( + r#" + UPDATE sbtc_signer.bitcoin_blocks + SET confirms = array_append(confirms, $1) + WHERE block_height = $2; + "#, + ) + .bind(&self.withdrawal_request.block_hash) + .bind(self.sweep_block_height as i64) + .execute(db.pool()) + .await + .unwrap(); + + let withdrawal_request = model::WithdrawalRequest { + request_id: self.withdrawal_request.request_id, + txid: self.withdrawal_request.txid, + block_hash: self.withdrawal_request.block_hash, + recipient: self.withdrawal_request.clone().script_pubkey, + amount: self.withdrawal_request.amount, + max_fee: self.withdrawal_request.max_fee, + sender_address: self.withdrawal_sender.clone().into(), + }; + db.write_withdrawal_request(&withdrawal_request) + .await + .unwrap(); + } + + /// We need to have a row in the dkg_shares table for the scriptPubKey + /// associated with the signers aggregate key. + pub async fn store_dkg_shares(&self, db: &PgStore) { + let aggregate_key: PublicKey = self.aggregated_signer.keypair.public_key().into(); + let shares = EncryptedDkgShares { + script_pubkey: aggregate_key.signers_script_pubkey().into(), + tweaked_aggregate_key: aggregate_key.signers_tweaked_pubkey().unwrap(), + encrypted_private_shares: Vec::new(), + public_shares: Vec::new(), + aggregate_key, + }; + db.write_encrypted_dkg_shares(&shares).await.unwrap(); + } +} + +/// Fetch all block headers from bitcoin-core and store it in the database. +pub async fn backfill_bitcoin_blocks(db: &PgStore, rpc: &Client, chain_tip: &bitcoin::BlockHash) { + let mut block_header = rpc.get_block_header_info(&chain_tip).unwrap(); + + // There are no non-coinbase transactions below this height. + while block_header.height as u64 >= regtest::MIN_BLOCKCHAIN_HEIGHT { + let parent_header_hash = block_header.previous_block_hash.unwrap(); + let bitcoin_block = model::BitcoinBlock { + block_hash: block_header.hash.into(), + block_height: block_header.height as u64, + parent_hash: parent_header_hash.into(), + confirms: Vec::new(), + }; + + db.write_bitcoin_block(&bitcoin_block).await.unwrap(); + block_header = rpc.get_block_header_info(&parent_header_hash).unwrap(); + } +} diff --git a/signer/tests/integration/utxo_construction.rs b/signer/tests/integration/utxo_construction.rs index 35cf2d93..7d3f45ab 100644 --- a/signer/tests/integration/utxo_construction.rs +++ b/signer/tests/integration/utxo_construction.rs @@ -13,7 +13,7 @@ use bitcoin::TxIn; use bitcoin::TxOut; use bitcoin::Witness; use bitcoin::XOnlyPublicKey; -use bitcoincore_rpc::RpcApi; +use bitcoincore_rpc::RpcApi as _; use bitvec::array::BitArray; use clarity::vm::types::PrincipalData; use fake::Fake; @@ -38,10 +38,11 @@ pub static REQUEST_IDS: AtomicU64 = AtomicU64::new(0); pub fn generate_withdrawal() -> (WithdrawalRequest, Recipient) { let recipient = Recipient::new(AddressType::P2tr); + let amount = OsRng.sample(Uniform::new(200_000, 250_000)); let req = WithdrawalRequest { - amount: OsRng.sample(Uniform::new(100_000, 250_000)), - max_fee: 250_000, + amount, + max_fee: amount / 2, script_pubkey: recipient.script_pubkey.clone().into(), signer_bitmap: BitArray::ZERO, request_id: REQUEST_IDS.fetch_add(1, Ordering::Relaxed), @@ -64,7 +65,7 @@ where let fee = regtest::BITCOIN_CORE_FALLBACK_FEE.to_sat(); let deposit_inputs = DepositScriptInputs { signers_public_key, - max_fee: amount, + max_fee: amount / 2, recipient: PrincipalData::from(StacksAddress::burn_address(false)), }; let reclaim_inputs = ReclaimScriptInputs::try_new(50, ScriptBuf::new()).unwrap(); diff --git a/signer/tests/integration/withdrawal_accept.rs b/signer/tests/integration/withdrawal_accept.rs index 470ae8d1..db5f2da5 100644 --- a/signer/tests/integration/withdrawal_accept.rs +++ b/signer/tests/integration/withdrawal_accept.rs @@ -1,84 +1,79 @@ use std::sync::atomic::Ordering; -use bitcoin::consensus::Encodable; -use bitcoin::hashes::Hash as _; use bitcoin::OutPoint; -use bitvec::array::BitArray; use blockstack_lib::types::chainstate::StacksAddress; - use rand::rngs::OsRng; +use sbtc::testing::regtest; use signer::error::Error; -use signer::keys::PublicKey; use signer::stacks::contracts::AcceptWithdrawalV1; use signer::stacks::contracts::AsContractCall as _; use signer::stacks::contracts::ReqContext; use signer::stacks::contracts::WithdrawalErrorMsg; -use signer::storage::model; -use signer::storage::model::BitcoinBlock; -use signer::storage::model::BitcoinTx; -use signer::storage::model::BitcoinTxRef; -use signer::storage::model::RotateKeysTransaction; -use signer::storage::model::WithdrawalRequest; -use signer::storage::postgres::PgStore; -use signer::storage::DbRead as _; -use signer::storage::DbWrite as _; +use signer::storage::model::BitcoinBlockRef; +use signer::storage::model::BitcoinTxId; use signer::testing; -use signer::testing::dummy::SweepTxConfig; -use signer::testing::storage::model::TestData; use fake::Fake; use rand::SeedableRng; use signer::testing::TestSignerContext; +use crate::setup::backfill_bitcoin_blocks; +use crate::setup::TestSweepSetup; use crate::DATABASE_NUM; /// Create a "proper" [`AcceptWithdrawalV1`] object and context with the /// given information. If the information here is correct then the returned /// [`AcceptWithdrawalV1`] object will pass validation with the given /// context. -fn make_withdrawal_accept( - req: &WithdrawalRequest, - outpoint: OutPoint, - aggregate_key: PublicKey, - chain_tip: &BitcoinBlock, - bitmap: BitArray<[u8; 16]>, -) -> (AcceptWithdrawalV1, ReqContext) { - // Creating `AcceptWithdrawalV1` transactions are tricky. They are a - // mix of data from the bitcoin transaction sweeping out the funds, - // the withdrawal request itself, and how the signers voted. +fn make_withdrawal_accept(data: &TestSweepSetup) -> (AcceptWithdrawalV1, ReqContext) { + // Okay now we get ready to create the transaction using the + // `AcceptWithdrawalV1` type. + let fee = data.sweep_tx_info.assess_output_fee(2).unwrap().to_sat(); let complete_withdrawal_tx = AcceptWithdrawalV1 { // This OutPoint points to the withdrawal UTXO. We look up our // record of the actual withdrawal to make sure that the amount // matches the one in the withdrawal request. - outpoint, - // This amount must not exceed the amount in the withdrawal request. - request_id: req.request_id, - // The recipient must match what was indicated in the withdrawal + outpoint: OutPoint { + txid: data.sweep_tx_info.txid, + // The sweep transaction has exactly 3 outputs, where the first + // two are about the signers and the third one is for the + // withdrawal request. + vout: 2, + }, + // This points to the withdrawal request transaction. + request_id: data.withdrawal_request.request_id, + // This is the assessed transaction fee for fulfilling the withdrawal // request. - tx_fee: 0, + tx_fee: fee, // - signer_bitmap: bitmap, + signer_bitmap: data.withdrawal_request.signer_bitmap, // The deployer must match what is in the signers' context. deployer: StacksAddress::burn_address(false), // The block hash of the block that includes the above sweep // transaction. It must be on the canonical bitcoin blockchain. - sweep_block_hash: chain_tip.block_hash, + sweep_block_hash: data.sweep_block_hash.into(), // This must be the height of the above block. - sweep_block_height: chain_tip.block_height, + sweep_block_height: data.sweep_block_height, }; - // This is what the current signer things of the state of things. + // This is what the current signer thinks is the state of things. let req_ctx = ReqContext { - chain_tip: chain_tip.into(), - // This value means that the signer will go back 10 blocks when + chain_tip: BitcoinBlockRef { + block_hash: data.sweep_block_hash.into(), + block_height: data.sweep_block_height, + }, + // This value means that the signer will go back 20 blocks when // looking for pending and accepted withdrawal requests. - context_window: 10, + context_window: 20, // The value here doesn't matter. origin: fake::Faker.fake_with_rng(&mut OsRng), - // The value here doesn't matter either. - aggregate_key, - // This value affects how many withdrawal transactions are consider - // accepted. + // When checking whether the transaction is from the signer, we + // check that the first "prevout" has a `scriptPubKey` that the + // signers control. + aggregate_key: data.aggregated_signer.keypair.public_key().into(), + // This value affects whether a withdrawal request is considered + // "accepted". During validation, a signer won't sign a transaction + // if it is not considered accepted but the collection of signers. signatures_required: 2, // This is who the current signer thinks deployed the sBTC // contracts. @@ -88,164 +83,42 @@ fn make_withdrawal_accept( (complete_withdrawal_tx, req_ctx) } -/// Generate a signer set, withdrawal requests and store them into the -/// database. -async fn withdrawal_setup( - rng: &mut R, - db: &PgStore, - signatures_required: u16, -) -> (PublicKey, Vec) -where - R: rand::RngCore + rand::CryptoRng, -{ - // This is just a sql test, where we use the `TestData` struct to help - // populate the database with test data. We set all the other - // unnecessary parameters to zero. - let num_signers = 7; - let test_model_params = testing::storage::model::Params { - num_bitcoin_blocks: 20, - num_stacks_blocks_per_bitcoin_block: 0, - num_deposit_requests_per_block: 0, - num_withdraw_requests_per_block: 2, - num_signers_per_request: num_signers, - }; - - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. - let signer_set = testing::wsts::generate_signer_set_public_keys(rng, num_signers); - let test_data = TestData::generate(rng, &signer_set, &test_model_params); - test_data.write_to(db).await; - - let aggregate_key = PublicKey::combine_keys(&signer_set).unwrap(); - let rotate_keys = RotateKeysTransaction { - txid: fake::Faker.fake_with_rng(rng), - aggregate_key, - signer_set: signer_set.clone(), - signatures_required, - }; - // Before we can write the rotate keys into the postgres database, we - // need to have a transaction in the transactions table. - let rotate_keys_tx = model::Transaction { - txid: rotate_keys.txid.into_bytes(), - tx: Vec::new(), - tx_type: model::TransactionType::RotateKeys, - block_hash: fake::Faker.fake_with_rng(rng), - }; - db.write_transaction(&rotate_keys_tx).await.unwrap(); - db.write_rotate_keys_transaction(&rotate_keys) - .await - .unwrap(); - - (aggregate_key, signer_set) -} - -/// Get the full block -async fn get_bitcoin_canonical_chain_tip_block(store: &PgStore) -> BitcoinBlock { - sqlx::query_as::<_, BitcoinBlock>( - "SELECT - block_hash - , block_height - , parent_hash - , confirms - FROM sbtc_signer.bitcoin_blocks - ORDER BY block_height DESC, block_hash DESC - LIMIT 1", - ) - .fetch_one(store.pool()) - .await - .unwrap() -} - -/// Get an existing pending and accepted withdrawal request that has been -/// confirmed on the canonical bitcoin blockchain. -/// -/// The signatures required field affects which deposit requests are -/// eligible for being accepted. In these tests, we just need any old -/// deposit request so this value doesn't matter so long as we get one -/// deposit request that meets these requirements. -async fn get_pending_accepted_withdrawal_requests( - db: &PgStore, - chain_tip: &BitcoinBlock, - signatures_required: u16, -) -> WithdrawalRequest { - // The context window limits how far back we look in the blockchain for - // accepted and pending deposit requests. For these tests, this value - // is fine. - db.get_pending_accepted_withdrawal_requests(&chain_tip.block_hash, 20, signatures_required) - .await - .unwrap() - .pop() - .unwrap() -} - -/// Get how the signers voted for a particular withdrawal request -async fn get_withdrawal_request_signer_votes( - db: &PgStore, - req: &WithdrawalRequest, - aggregate_key: &PublicKey, -) -> BitArray<[u8; 16]> { - db.get_withdrawal_request_signer_votes(&req.qualified_id(), &aggregate_key) - .await - .map(BitArray::<[u8; 16]>::from) - .unwrap() -} - /// For this test we check that the `AcceptWithdrawalV1::validate` function /// returns okay when everything matches the way that it is supposed to. #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn accept_withdrawal_validation_happy_path() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. This is just setup + // and should be essentially the same between tests. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 3; - - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, signatures_required).await; - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing withdrawal request on the canonical bitcoin - // blockchain. - let req = get_pending_accepted_withdrawal_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps out the withdrawal. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount, req.recipient.clone())], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); - // Normal: we get the outpoint of the UTXO in the sweep transactions. - // Sweep transactions start withdrawal UTXOs at the third output. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 2); - - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - let sweep_tx = model::Transaction { - txid: sweep_btc_tx.compute_txid().to_byte_array(), - tx: tx_bytes, - tx_type: model::TransactionType::SbtcTransaction, - block_hash: chain_tip.block_hash.into_bytes(), - }; + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; - // Normal: get the signer bitmap for how they voted. - let bitmap = get_withdrawal_request_signer_votes(&db, &req, &aggregate_key).await; // Generate the transaction and corresponding request context. - let (accept_withdrawal_tx, req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip, bitmap); + let (accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); // This should not return an Err. let ctx = TestSignerContext::from_db(db.clone()); @@ -260,60 +133,39 @@ async fn accept_withdrawal_validation_happy_path() { #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn accept_withdrawal_validation_deployer_mismatch() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 3; - - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, signatures_required).await; - - // Get the chain tip - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing withdrawal request on the canonical bitcoin - // blockchain. - let req = get_pending_accepted_withdrawal_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps out the withdrawal. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount, req.recipient.clone())], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); - // Normal: we get the outpoint of the UTXO in the sweep transactions. - // Sweep transactions start withdrawal UTXOs at the third output. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 2); - - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - let sweep_tx = model::Transaction { - txid: sweep_btc_tx.compute_txid().to_byte_array(), - tx: tx_bytes, - tx_type: model::TransactionType::SbtcTransaction, - block_hash: chain_tip.block_hash.into_bytes(), - }; - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; - // Normal: get the signer bitmap for how they voted. - let bitmap = get_withdrawal_request_signer_votes(&db, &req, &aggregate_key).await; // Generate the transaction and corresponding request context. - let (mut accept_withdrawal_tx, mut req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip, bitmap); + let (mut accept_withdrawal_tx, mut req_ctx) = make_withdrawal_accept(&setup); // Different: Okay, let's make sure the deployers do not match. - accept_withdrawal_tx.deployer = StacksAddress::p2pkh(false, &signer_set[0].into()); - req_ctx.deployer = StacksAddress::p2pkh(false, &signer_set[1].into()); + accept_withdrawal_tx.deployer = StacksAddress::p2pkh(false, &setup.signer_keys[0].into()); + req_ctx.deployer = StacksAddress::p2pkh(false, &setup.signer_keys[1].into()); let ctx = TestSignerContext::from_db(db.clone()); let validate_future = accept_withdrawal_tx.validate(&ctx, &req_ctx); @@ -334,55 +186,40 @@ async fn accept_withdrawal_validation_deployer_mismatch() { #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn accept_withdrawal_validation_missing_withdrawal_request() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, 3).await; - - // Normal: Get the chain tip and any pending withdrawal request in the blockchain - // identified by the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Different: Let's use a random withdrawal request instead of one that - // exists in the database. - let req: WithdrawalRequest = fake::Faker.fake_with_rng(&mut rng); - - // Normal: we generate a transaction that sweeps out the withdrawal. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount, req.recipient.clone())], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); - // Normal: we get the outpoint of the UTXO in the sweep transactions. - // Sweep transactions start withdrawal UTXOs at the third output. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 2); - - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - let sweep_tx = model::Transaction { - txid: sweep_btc_tx.compute_txid().to_byte_array(), - tx: tx_bytes, - tx_type: model::TransactionType::SbtcTransaction, - block_hash: chain_tip.block_hash.into_bytes(), - }; - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; - let bitmap = get_withdrawal_request_signer_votes(&db, &req, &aggregate_key).await; - let (accept_withdrawal_tx, req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip, bitmap); + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; + + // Generate the transaction and corresponding request context. + let (mut accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); + // Different: Let's use a request_id that does not exist in our + // database. In these tests, the withdrawal id starts at 0 and + // increments by 1 for each withdrawal request generated. + accept_withdrawal_tx.request_id = u64::MAX; let ctx = TestSignerContext::from_db(db.clone()); let validation_result = accept_withdrawal_tx.validate(&ctx, &req_ctx).await; @@ -403,58 +240,40 @@ async fn accept_withdrawal_validation_missing_withdrawal_request() { #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn accept_withdrawal_validation_recipient_mismatch() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 3; - - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, signatures_required).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing withdrawal request on the canonical bitcoin - // blockchain. - let req = get_pending_accepted_withdrawal_requests(&db, &chain_tip, signatures_required).await; - - // Different: we generate a transaction that sweeps out the withdrawal, - // but the recipient of the funds does not match. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount, fake::Faker.fake_with_rng(&mut rng))], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); - // Normal: we get the outpoint of the UTXO in the sweep transactions. - // Sweep transactions start withdrawal UTXOs at the third output. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 2); - - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - let sweep_tx = model::Transaction { - txid: sweep_btc_tx.compute_txid().to_byte_array(), - tx: tx_bytes, - tx_type: model::TransactionType::SbtcTransaction, - block_hash: chain_tip.block_hash.into_bytes(), - }; - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + let (rpc, faucet) = regtest::initialize_blockchain(); + let mut setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Different: the sweep transaction has already taken place, but our + // records of the recipient does not match the actual recipient on + // chain. + setup.withdrawal_request.script_pubkey = fake::Faker.fake_with_rng(&mut rng); + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; - // Normal: get the signer bitmap for how they voted. - let bitmap = get_withdrawal_request_signer_votes(&db, &req, &aggregate_key).await; // Generate the transaction and corresponding request context. - let (accept_withdrawal_tx, req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip, bitmap); + let (accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); let ctx = TestSignerContext::from_db(db.clone()); let validation_result = accept_withdrawal_tx.validate(&ctx, &req_ctx).await; @@ -469,64 +288,44 @@ async fn accept_withdrawal_validation_recipient_mismatch() { } /// For this test we check that the `AcceptWithdrawalV1::validate` function -/// returns a withdrawal validation error with a InvalidMintAmount message +/// returns a withdrawal validation error with a InvalidAmount message /// when the amount of sBTC to mint exceeds the amount in the signer's /// withdrawal request record. #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] -async fn accept_withdrawal_validation_invalid_mint_amount() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. +async fn accept_withdrawal_validation_invalid_amount() { + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 3; - - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, signatures_required).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing withdrawal request on the canonical bitcoin - // blockchain. - let req = get_pending_accepted_withdrawal_requests(&db, &chain_tip, signatures_required).await; - - // Different: we generate a transaction that sweeps out the withdrawal, - // but the amount is off. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount + 1, req.recipient.clone())], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); - // Normal: we get the outpoint of the UTXO in the sweep transactions. - // Sweep transactions start withdrawal UTXOs at the third output. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 2); - - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - let sweep_tx = model::Transaction { - txid: sweep_btc_tx.compute_txid().to_byte_array(), - tx: tx_bytes, - tx_type: model::TransactionType::SbtcTransaction, - block_hash: chain_tip.block_hash.into_bytes(), - }; - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + let (rpc, faucet) = regtest::initialize_blockchain(); + let mut setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Different: the request amount and the amount on chain do not match. + setup.withdrawal_request.amount += 1; + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; - // Normal: get the signer bitmap for how they voted. - let bitmap = get_withdrawal_request_signer_votes(&db, &req, &aggregate_key).await; // Generate the transaction and corresponding request context. - let (accept_withdrawal_tx, req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip, bitmap); + let (accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); let ctx = TestSignerContext::from_db(db.clone()); let validation_result = accept_withdrawal_tx.validate(&ctx, &req_ctx).await; @@ -547,69 +346,46 @@ async fn accept_withdrawal_validation_invalid_mint_amount() { #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn accept_withdrawal_validation_invalid_fee() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 3; - - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, signatures_required).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing withdrawal request on the canonical bitcoin - // blockchain. - let req = get_pending_accepted_withdrawal_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps out the withdrawal. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount, req.recipient.clone())], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); - // Normal: we get the outpoint of the UTXO in the sweep transactions. - // Sweep transactions start withdrawal UTXOs at the third output. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 2); - - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - let sweep_tx = model::Transaction { - txid: sweep_btc_tx.compute_txid().to_byte_array(), - tx: tx_bytes, - tx_type: model::TransactionType::SbtcTransaction, - block_hash: chain_tip.block_hash.into_bytes(), - }; - - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + let (rpc, faucet) = regtest::initialize_blockchain(); + let mut setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Different: The fee cannot exceed the max fee. As usual, we still + // need to store the withdrawal request and how the signers voted. + let assessed_fee = setup.sweep_tx_info.assess_output_fee(2).unwrap().to_sat(); + setup.withdrawal_request.max_fee = assessed_fee - 1; + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; - // Normal: get the signer bitmap for how they voted. - let bitmap = get_withdrawal_request_signer_votes(&db, &req, &aggregate_key).await; // Generate the transaction and corresponding request context. - let (mut accept_withdrawal_tx, req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip, bitmap); - // Different: The fee cannot exceed the max fee. Setting the `tx_fee` - // to `max_fee + 1` here will result in the validation validating - // `req.value - (req.max_fee + 1)`, which will then be less than - // `req.value - req.max_fee` and thus invalid. - accept_withdrawal_tx.tx_fee = req.max_fee + 1; + let (accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); let ctx = TestSignerContext::from_db(db.clone()); let validate_future = accept_withdrawal_tx.validate(&ctx, &req_ctx); match validate_future.await.unwrap_err() { Error::WithdrawalAcceptValidation(ref err) => { - assert_eq!(err.error, WithdrawalErrorMsg::InvalidFee) + assert_eq!(err.error, WithdrawalErrorMsg::FeeTooHigh) } err => panic!("unexpected error during validation {err}"), } @@ -624,44 +400,42 @@ async fn accept_withdrawal_validation_invalid_fee() { #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn accept_withdrawal_validation_sweep_tx_missing() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 3; - - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, signatures_required).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing withdrawal request on the canonical bitcoin - // blockchain. - let req = get_pending_accepted_withdrawal_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps out the withdrawal. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount, req.recipient.clone())], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); - // Normal: we get the outpoint of the UTXO in the sweep transactions. - // Sweep transactions start withdrawal UTXOs at the third output. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 2); - - // Different: we are supposed to store a sweep transaction, but we do - // not do that here. Now this signer does not have a record of the - // sweep transaction. - - // Normal: get the signer bitmap for how they voted. - let bitmap = get_withdrawal_request_signer_votes(&db, &req, &aggregate_key).await; + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; + // Generate the transaction and corresponding request context. - let (accept_withdrawal_tx, req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip, bitmap); + let (mut accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); + + // Different: there is supposed to be sweep transaction in + // bitcoin-core, but we make sure that such a transaction does not + // exist. + let fake_txid: BitcoinTxId = fake::Faker.fake_with_rng(&mut rng); + accept_withdrawal_tx.outpoint.txid = fake_txid.into(); let ctx = TestSignerContext::from_db(db.clone()); let validation_result = accept_withdrawal_tx.validate(&ctx, &req_ctx).await; @@ -682,80 +456,50 @@ async fn accept_withdrawal_validation_sweep_tx_missing() { #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn accept_withdrawal_validation_sweep_reorged() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 3; - - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, signatures_required).await; - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing withdrawal request on the canonical bitcoin - // blockchain. - let req = get_pending_accepted_withdrawal_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps out the withdrawal. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount, req.recipient.clone())], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); - // Normal: we get the outpoint of the UTXO in the sweep transactions. - // Sweep transactions start withdrawal UTXOs at the third output. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 2); - - // Different: In this case the transaction that sweeps in the - // withdrawal gets confirmed, but on a bitcoin blockchain that is not - // the canonical one. So we generate a new blockchain and put it there. - // - // Note that this blockchain might actually have a greater height, but - // we get to say which one is the canonical one in our context so that - // fact doesn't matter in this test. - let test_model_params = testing::storage::model::Params { - num_bitcoin_blocks: 10, - num_stacks_blocks_per_bitcoin_block: 0, - num_deposit_requests_per_block: 0, - num_withdraw_requests_per_block: 0, - num_signers_per_request: 0, - }; - let test_data2 = TestData::generate(&mut rng, &signer_set, &test_model_params); - test_data2.write_to(&db).await; - let chain_tip2 = test_data2 - .bitcoin_blocks - .iter() - .max_by_key(|x| (x.block_height, x.block_hash)) - .unwrap(); - let sweep_tx = model::Transaction { - txid: sweep_btc_tx.compute_txid().to_byte_array(), - tx: tx_bytes, - tx_type: model::TransactionType::SbtcTransaction, - block_hash: chain_tip2.block_hash.into_bytes(), - }; - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; - // Normal: get the signer bitmap for how they voted. - let bitmap = get_withdrawal_request_signer_votes(&db, &req, &aggregate_key).await; // Generate the transaction and corresponding request context. - let (accept_withdrawal_tx, mut req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip2, bitmap); - // Different: We already created the BTC transaction that swept out the - // users funds and confirmed it on a bitcoin blockchain identified by - // `chain_tip2`. Here we set the canonical chain tip on the context to - // be `chain_tip1`. - req_ctx.chain_tip = chain_tip.into(); + let (accept_withdrawal_tx, mut req_ctx) = make_withdrawal_accept(&setup); + + // Different: the transaction that sweeps in the withdrawal has been + // confirmed, but let's suppose that it gets confirmed on a bitcoin + // blockchain that is not the canonical one. To test that we set a + // chain tip to be some other blockchain. The important part is that + // our sweep transaction is not on the canonical one. + req_ctx.chain_tip = BitcoinBlockRef { + block_hash: fake::Faker.fake_with_rng(&mut rng), + // This value kind of matters, but that's more of an implementation + // detail. All that should matter is that the block_hash does not + // identify the bitcoin blockchain that includes the sweep + // transaction. + block_height: 30000, + }; let ctx = TestSignerContext::from_db(db.clone()); let validation_result = accept_withdrawal_tx.validate(&ctx, &req_ctx).await; @@ -777,62 +521,42 @@ async fn accept_withdrawal_validation_sweep_reorged() { #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn accept_withdrawal_validation_withdrawal_not_in_sweep() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 3; - - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, signatures_required).await; - - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing withdrawal request on the canonical bitcoin - // blockchain. - let req = get_pending_accepted_withdrawal_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps out the withdrawal. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount, req.recipient.clone())], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; + + // Generate the transaction and corresponding request context. + let (mut accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); // Different: the outpoint here is supposed to be the outpoint of the // UTXO in the sweep transactions that spends to the desired recipient. // Here we give an outpoint that doesn't exist in the transaction, // triggering the desired error. We use 3 for the vout, but any number // greater than 2 will do. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 3); - - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - let sweep_tx = model::Transaction { - txid: sweep_btc_tx.compute_txid().to_byte_array(), - tx: tx_bytes, - tx_type: model::TransactionType::SbtcTransaction, - block_hash: chain_tip.block_hash.into_bytes(), - }; - - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); - - // Normal: get the signer bitmap for how they voted. - let bitmap = get_withdrawal_request_signer_votes(&db, &req, &aggregate_key).await; - // Generate the transaction and corresponding request context. - // Different: using the "invalid" `sweep_outpoint` we created above. - let (accept_withdrawal_tx, req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip, bitmap); + accept_withdrawal_tx.outpoint = OutPoint::new(setup.sweep_tx_info.txid, 3); let ctx = TestSignerContext::from_db(db.clone()); let validation_result = accept_withdrawal_tx.validate(&ctx, &req_ctx).await; @@ -853,64 +577,40 @@ async fn accept_withdrawal_validation_withdrawal_not_in_sweep() { #[cfg_attr(not(feature = "integration-tests"), ignore)] #[tokio::test] async fn accept_withdrawal_validation_bitmap_mismatch() { - // Normal: this generates the blockchain as well as withdrawal request - // transactions in each bitcoin block. + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); let db = testing::storage::new_test_database(db_num, true).await; let mut rng = rand::rngs::StdRng::seed_from_u64(51); - let signatures_required = 3; - - let (aggregate_key, signer_set) = withdrawal_setup(&mut rng, &db, signatures_required).await; - // Get the chain tip. - let chain_tip = get_bitcoin_canonical_chain_tip_block(&db).await; - // Normal: Get an existing withdrawal request on the canonical bitcoin - // blockchain. - let req = get_pending_accepted_withdrawal_requests(&db, &chain_tip, signatures_required).await; - - // Normal: we generate a transaction that sweeps out the withdrawal. - let sweep_config = SweepTxConfig { - aggregate_key: PublicKey::combine_keys(&signer_set).unwrap(), - amounts: 3000..1_000_000_000, - inputs: Vec::new(), - outputs: vec![(req.amount, req.recipient.clone())], - }; - let sweep_btc_tx: BitcoinTx = sweep_config.fake_with_rng(&mut rng); - let mut tx_bytes = Vec::new(); - sweep_btc_tx.consensus_encode(&mut tx_bytes).unwrap(); - // Normal: we get the outpoint of the UTXO in the sweep transactions. - // Sweep transactions start withdrawal UTXOs at the third output. - let sweep_outpoint = OutPoint::new(sweep_btc_tx.compute_txid(), 2); - - // Normal: make sure the sweep transaction is on the canonical bitcoin - // blockchain and is in our database. - let sweep_tx = model::Transaction { - txid: sweep_btc_tx.compute_txid().to_byte_array(), - tx: tx_bytes, - tx_type: model::TransactionType::SbtcTransaction, - block_hash: chain_tip.block_hash.into_bytes(), - }; + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); - // Normal: make sure that we have a record of the sweep transaction in - // our database. - let bitcoin_tx_ref = BitcoinTxRef { - txid: sweep_tx.txid.into(), - block_hash: sweep_tx.block_hash.into(), - }; - db.write_transaction(&sweep_tx).await.unwrap(); - db.write_bitcoin_transaction(&bitcoin_tx_ref).await.unwrap(); + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; + // Generate the transaction and corresponding request context. + let (mut accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); // Different: We're going to get the bitmap that is a little different // from what is expected. - let mut bitmap = db - .get_withdrawal_request_signer_votes(&req.qualified_id(), &aggregate_key) - .await - .map(BitArray::<[u8; 16]>::from) - .unwrap(); - let first_vote = *bitmap.get(0).unwrap(); - bitmap.set(0, !first_vote); - // Generate the transaction and corresponding request context. - let (accept_withdrawal_tx, req_ctx) = - make_withdrawal_accept(&req, sweep_outpoint, aggregate_key, &chain_tip, bitmap); + let first_vote = *accept_withdrawal_tx.signer_bitmap.get(0).unwrap(); + accept_withdrawal_tx.signer_bitmap.set(0, !first_vote); let ctx = TestSignerContext::from_db(db.clone()); let validation_result = accept_withdrawal_tx.validate(&ctx, &req_ctx).await; @@ -923,3 +623,108 @@ async fn accept_withdrawal_validation_bitmap_mismatch() { testing::storage::drop_db(db).await; } + +/// For this test we check that the `AcceptWithdrawalV1::validate` function +/// returns a withdrawal validation error with a IncorrectFee message when +/// the sweep transaction is in our records, is on what the signer thinks +/// is the canonical bitcoin blockchain, but the supplied transaction +/// object does not have what we think should be the correct fee. +#[cfg_attr(not(feature = "integration-tests"), ignore)] +#[tokio::test] +async fn accept_withdrawal_validation_withdrawal_incorrect_fee() { + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. + let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); + let db = testing::storage::new_test_database(db_num, true).await; + let mut rng = rand::rngs::StdRng::seed_from_u64(51); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Normal: we need to store a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. + setup.store_dkg_shares(&db).await; + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; + + // Generate the transaction and corresponding request context. + let (mut accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); + // Different: the fee here is less than we would think that it + // should be. + accept_withdrawal_tx.tx_fee -= 1; + + let ctx = TestSignerContext::from_db(db.clone()); + let validation_result = accept_withdrawal_tx.validate(&ctx, &req_ctx).await; + match validation_result.unwrap_err() { + Error::WithdrawalAcceptValidation(ref err) => { + assert_eq!(err.error, WithdrawalErrorMsg::IncorrectFee) + } + err => panic!("unexpected error during validation {err}"), + } + + testing::storage::drop_db(db).await; +} + +/// For this test we check that the `AcceptWithdrawalV1::validate` function +/// returns a withdrawal validation error with a InvalidSweep message when +/// the sweep transaction does not have a prevout with a scriptPubKey that +/// the signers control. +#[cfg_attr(not(feature = "integration-tests"), ignore)] +#[tokio::test] +async fn accept_withdrawal_validation_withdrawal_invalid_sweep() { + // Normal: this generates the blockchain as well as a transaction + // sweeping out the funds for a withdrawal request. + let db_num = DATABASE_NUM.fetch_add(1, Ordering::SeqCst); + let db = testing::storage::new_test_database(db_num, true).await; + let mut rng = rand::rngs::StdRng::seed_from_u64(51); + let (rpc, faucet) = regtest::initialize_blockchain(); + let setup = TestSweepSetup::new_setup(&rpc, &faucet, 1_000_000, &mut rng); + + // Normal: the signer follows the bitcoin blockchain and event observer + // should be getting new block events from bitcoin-core. We haven't + // hooked up our block observer, so we need to manually update the + // database with new bitcoin block headers. + backfill_bitcoin_blocks(&db, rpc, &setup.sweep_block_hash).await; + + // Normal: we take the sweep transaction as is from the test setup and + // store it in the database. + setup.store_sweep_tx(&db).await; + + // Different: we normally add a row in the dkg_shares table so that we + // have a record of the scriptPubKey that the signers control. Here we + // exclude it, so it looks like the first UTXO in the transaction is not + // controlled by the signers. + + // Normal: the request and how the signers voted needs to be added to + // the database. Here the bitmap in the withdrawal request object + // corresponds to how the signers voted. + setup.store_withdrawal_request(&db).await; + setup.store_withdrawal_decisions(&db).await; + + // Generate the transaction and corresponding request context. + let (accept_withdrawal_tx, req_ctx) = make_withdrawal_accept(&setup); + + let ctx = TestSignerContext::from_db(db.clone()); + let validation_result = accept_withdrawal_tx.validate(&ctx, &req_ctx).await; + match validation_result.unwrap_err() { + Error::WithdrawalAcceptValidation(ref err) => { + assert_eq!(err.error, WithdrawalErrorMsg::InvalidSweep) + } + err => panic!("unexpected error during validation {err}"), + } + + testing::storage::drop_db(db).await; +}