diff --git a/signer/src/stacks/contracts.rs b/signer/src/stacks/contracts.rs index 41d368f93..8352afcc7 100644 --- a/signer/src/stacks/contracts.rs +++ b/signer/src/stacks/contracts.rs @@ -59,6 +59,7 @@ use crate::storage::model::ToLittleEndianOrder as _; use crate::storage::DbRead; use crate::DEPOSIT_DUST_LIMIT; use crate::WITHDRAWAL_BLOCKS_EXPIRY; +use crate::WITHDRAWAL_MIN_CONFIRMATIONS; use super::api::StacksInteract; @@ -1029,6 +1030,10 @@ pub enum WithdrawalRejectErrorMsg { /// been either accepted or rejected already. #[error("the smart contract has been updated to indicate that the request has been completed")] RequestCompleted, + /// A withdrawal request is still active if it's reasonably possible + /// for the request to be fulfilled by a sweep transaction on bitcoin. + #[error("the withdrawal request is still active, we cannot reject it yet")] + RequestStillActive, /// Withdrawal request fulfilled #[error("Withdrawal request fulfilled")] RequestFulfilled, @@ -1109,6 +1114,9 @@ impl AsContractCall for RejectWithdrawalV1 { /// 6. Whether the withdrawal request has expired. Fail if it hasn't. /// 7. Whether the withdrawal request is being serviced by a sweep /// transaction that is in the mempool. + /// 8. Whether we need to worry about forks causing the withdrawal to + /// be confirmed by a sweep that was broadcast changing the status + /// of the request from rejected to accepted. async fn validate(&self, ctx: &C, req_ctx: &ReqContext) -> Result<(), Error> where C: Context + Send + Sync, @@ -1191,6 +1199,16 @@ impl AsContractCall for RejectWithdrawalV1 { return Err(WithdrawalRejectErrorMsg::RequestBeingFulfilled.into_error(req_ctx, self)); } + // 8. Check whether the withdrawal request is still active, as in + // it could still be fulfilled. + let withdrawal_is_active = db + .is_withdrawal_active(&self.id, &req_ctx.chain_tip, WITHDRAWAL_MIN_CONFIRMATIONS) + .await?; + + if withdrawal_is_active { + return Err(WithdrawalRejectErrorMsg::RequestStillActive.into_error(req_ctx, self)); + } + Ok(()) } } diff --git a/signer/src/storage/in_memory.rs b/signer/src/storage/in_memory.rs index 438def183..748cd6a71 100644 --- a/signer/src/storage/in_memory.rs +++ b/signer/src/storage/in_memory.rs @@ -822,6 +822,15 @@ impl super::DbRead for SharedStore { unimplemented!() } + async fn is_withdrawal_active( + &self, + _: &model::QualifiedRequestId, + _: &model::BitcoinBlockRef, + _: u64, + ) -> Result { + unimplemented!() + } + async fn get_bitcoin_tx( &self, txid: &model::BitcoinTxId, diff --git a/signer/src/storage/mod.rs b/signer/src/storage/mod.rs index 2fd7de419..aca29ff7f 100644 --- a/signer/src/storage/mod.rs +++ b/signer/src/storage/mod.rs @@ -336,6 +336,17 @@ pub trait DbRead { bitcoin_chain_tip: &model::BitcoinBlockHash, ) -> impl Future> + Send; + /// Returns whether we should consider the withdrawal active. A + /// withdrawal request is considered active if there is a reasonable + /// risk of the withdrawal being confirmed from a fork of blocks less + /// than `min_confirmations`. + fn is_withdrawal_active( + &self, + id: &model::QualifiedRequestId, + bitcoin_chain_tip: &model::BitcoinBlockRef, + min_confirmations: u64, + ) -> 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/postgres.rs b/signer/src/storage/postgres.rs index 5632bea89..b8f97a0e4 100644 --- a/signer/src/storage/postgres.rs +++ b/signer/src/storage/postgres.rs @@ -445,6 +445,37 @@ impl PgStore { Ok(pg_utxo.map(SignerUtxo::from)) } + /// This function returns the bitcoin block height of the first + /// confirmed sweep that happened on or after the given minimum block + /// height. + async fn get_least_txo_height( + &self, + chain_tip: &model::BitcoinBlockHash, + min_block_height: i64, + ) -> Result, Error> { + sqlx::query_scalar::<_, i64>( + r#" + SELECT bb.block_height + FROM sbtc_signer.bitcoin_tx_inputs AS bi + JOIN sbtc_signer.bitcoin_tx_outputs AS bo + ON bo.txid = bi.txid + JOIN sbtc_signer.bitcoin_transactions AS bt + ON bt.txid = bi.txid + JOIN bitcoin_blockchain_until($1, $2) AS bb + ON bb.block_hash = bt.block_hash + WHERE bo.output_type = 'signers_output' + AND bi.prevout_type = 'signers_input' + ORDER BY bb.block_height ASC + LIMIT 1; + "#, + ) + .bind(chain_tip) + .bind(min_block_height) + .fetch_optional(&self.0) + .await + .map_err(Error::SqlxQuery) + } + /// Return the height of the earliest block in which a donation UTXO /// has been confirmed. /// @@ -2121,6 +2152,69 @@ impl super::DbRead for PgStore { .map_err(Error::SqlxQuery) } + async fn is_withdrawal_active( + &self, + id: &model::QualifiedRequestId, + bitcoin_chain_tip: &model::BitcoinBlockRef, + min_confirmations: u64, + ) -> Result { + // We want to find the first sweep transaction that happened + // *after* the last time the signers considered sweeping the + // withdrawal request. We do this in two stages: + // 1. Find the block height of the last time that the signers + // considered the withdrawal request (the query right below this + // comment). + // 2. Find the least height of any sweep transaction confirmed in a + // block with height greater than the height returned from (1). + let last_considered_height = sqlx::query_scalar::<_, Option>( + r#" + SELECT MAX(bb.block_height) + FROM sbtc_signer.bitcoin_withdrawals_outputs AS bwo + JOIN sbtc_signer.bitcoin_blocks AS bb + ON bb.block_hash = bwo.bitcoin_chain_tip + WHERE bwo.request_id = $1 + AND bwo.stacks_block_hash = $2 + "#, + ) + .bind(i64::try_from(id.request_id).map_err(Error::ConversionDatabaseInt)?) + .bind(id.block_hash) + .fetch_one(&self.0) + .await + .map_err(Error::SqlxQuery)?; + + // We add one because we are interested in sweeps that were + // confirmed after the signers last considered the withdrawal. + let Some(min_block_height) = last_considered_height.map(|x| x + 1) else { + // This means that there are no rows associated with the ID in the + // `bitcoin_withdrawals_outputs` table. + return Ok(false); + }; + + // We now know that the signers considered this withdrawal request + // at least once. We now try to find the height of the oldest + // signer TXO whose height is greater than the height from above. + let chain_tip_hash = &bitcoin_chain_tip.block_hash; + let least_txo_height = self + .get_least_txo_height(chain_tip_hash, min_block_height) + .await? + .map(u64::try_from) + .transpose() + .map_err(|_| Error::TypeConversion)?; + // If this returns None, then the sweep itself could be in the + // mempool. If that's the case then this is definitely active. + let Some(least_txo_height) = least_txo_height else { + return Ok(true); + }; + // We test whether the TXO height has a minimum number of + // confirmations. If it doesn't have enough confirmations, then it + // is considered active since a fork could put it into the mempool + // again. + let txo_confirmations = bitcoin_chain_tip + .block_height + .saturating_sub(least_txo_height); + Ok(txo_confirmations <= min_confirmations) + } + async fn get_bitcoin_tx( &self, txid: &model::BitcoinTxId, diff --git a/signer/src/testing/dummy.rs b/signer/src/testing/dummy.rs index 2d3279c2e..9d05ba2d8 100644 --- a/signer/src/testing/dummy.rs +++ b/signer/src/testing/dummy.rs @@ -754,7 +754,7 @@ impl fake::Dummy for RotateKeysV1 { impl fake::Dummy for QualifiedRequestId { fn dummy_with_rng(config: &fake::Faker, rng: &mut R) -> Self { QualifiedRequestId { - request_id: config.fake_with_rng(rng), + request_id: config.fake_with_rng::(rng) as u64, txid: config.fake_with_rng(rng), block_hash: config.fake_with_rng(rng), } diff --git a/signer/src/transaction_coordinator.rs b/signer/src/transaction_coordinator.rs index 5df96c97c..b1f86bf4a 100644 --- a/signer/src/transaction_coordinator.rs +++ b/signer/src/transaction_coordinator.rs @@ -66,6 +66,7 @@ use crate::storage::DbRead as _; use crate::wsts_state_machine::FireCoordinator; use crate::wsts_state_machine::FrostCoordinator; use crate::wsts_state_machine::WstsCoordinator; +use crate::WITHDRAWAL_MIN_CONFIRMATIONS; use bitcoin::hashes::Hash as _; use wsts::net::SignatureType; @@ -781,13 +782,25 @@ where // the given withdrawal has been included in a sweep transaction // that could have been submitted. With this check we are more // confident that it is safe to reject the withdrawal. + let qualified_id = request.qualified_id(); let withdrawal_inflight = db - .is_withdrawal_inflight(&request.qualified_id(), &chain_tip.block_hash) + .is_withdrawal_inflight(&qualified_id, &chain_tip.block_hash) .await?; if withdrawal_inflight { return Ok(()); } + // The `DbRead::is_withdrawal_inflight` function considers whether + // we need to worry about a fork making a sweep fulfilling + // withdrawal active in the mempool. + let withdrawal_is_active = db + .is_withdrawal_active(&qualified_id, chain_tip, WITHDRAWAL_MIN_CONFIRMATIONS) + .await?; + + if withdrawal_is_active { + return Ok(()); + } + let sign_request_fut = self.construct_withdrawal_reject_stacks_sign_request( &request, bitcoin_aggregate_key, diff --git a/signer/tests/integration/postgres.rs b/signer/tests/integration/postgres.rs index ee0b18dd6..a0196ea2f 100644 --- a/signer/tests/integration/postgres.rs +++ b/signer/tests/integration/postgres.rs @@ -5471,6 +5471,8 @@ async fn is_withdrawal_inflight_catches_withdrawals_with_rows_in_table() { db.write_bitcoin_txs_sighashes(&[sighash]).await.unwrap(); assert!(db.is_withdrawal_inflight(&id, &chain_tip).await.unwrap()); + + signer::testing::storage::drop_db(db).await; } /// Check that is_withdrawal_inflight correctly picks up withdrawal @@ -5579,4 +5581,24 @@ async fn is_withdrawal_inflight_catches_withdrawals_in_package() { db.write_bitcoin_txs_sighashes(&[sighash1]).await.unwrap(); assert!(db.is_withdrawal_inflight(&id, &chain_tip).await.unwrap()); + + signer::testing::storage::drop_db(db).await; +} + +/// Check that is_withdrawal_active correctly returns false for unknown +/// withdrawal requests. +#[tokio::test] +async fn is_withdrawal_active_unknown_withdrawal() { + let db = testing::storage::new_test_database().await; + let mut rng = rand::rngs::StdRng::seed_from_u64(2); + + let chain_tip = Faker.fake_with_rng(&mut rng); + let qualified_id: QualifiedRequestId = Faker.fake_with_rng(&mut rng); + let active = db + .is_withdrawal_active(&qualified_id, &chain_tip, 1) + .await + .unwrap(); + assert!(!active); + + signer::testing::storage::drop_db(db).await; } diff --git a/signer/tests/integration/setup.rs b/signer/tests/integration/setup.rs index 29208e2d3..76be17e66 100644 --- a/signer/tests/integration/setup.rs +++ b/signer/tests/integration/setup.rs @@ -25,6 +25,7 @@ use signer::bitcoin::rpc::BitcoinCoreClient; use signer::bitcoin::rpc::BitcoinTxInfo; use signer::bitcoin::rpc::GetTxResponse; use signer::bitcoin::utxo; +use signer::bitcoin::utxo::Fees; use signer::bitcoin::utxo::SbtcRequests; use signer::bitcoin::utxo::SignerBtcState; use signer::bitcoin::utxo::SignerUtxo; @@ -44,6 +45,7 @@ use signer::storage::model::BitcoinBlock; use signer::storage::model::BitcoinBlockHash; use signer::storage::model::BitcoinBlockRef; use signer::storage::model::BitcoinTxRef; +use signer::storage::model::BitcoinTxSigHash; use signer::storage::model::BitcoinWithdrawalOutput; use signer::storage::model::DkgSharesStatus; use signer::storage::model::EncryptedDkgShares; @@ -621,6 +623,15 @@ pub struct SweepTxInfo { pub tx_info: BitcoinTxInfo, } +#[derive(Debug, Clone)] +pub struct BroadcastSweepTxInfo { + /// The block hash of the bitcoin chain tip when the sweep transaction + /// was broadcast + pub block_hash: bitcoin::BlockHash, + /// The transaction that swept in the deposit transaction. + pub txid: bitcoin::Txid, +} + #[derive(Debug, Clone, Copy)] pub struct SweepAmounts { pub amount: u64, @@ -655,6 +666,8 @@ pub struct TestSweepSetup2 { pub donation: OutPoint, /// The transaction that swept in the deposit transaction. pub sweep_tx_info: Option, + /// Information about the sweep transaction when it was broadcast. + pub broadcast_info: Option, /// The stacks blocks confirming the withdrawal requests, along with a /// genesis block. pub stacks_blocks: Vec, @@ -783,6 +796,7 @@ impl TestSweepSetup2 { deposit_block_hash, deposits, sweep_tx_info: None, + broadcast_info: None, donation, signers, stacks_blocks, @@ -847,13 +861,31 @@ impl TestSweepSetup2 { /// This function generates a sweep transaction that sweeps in the /// deposited funds and sweeps out the withdrawal funds in a proper - /// sweep transaction, that is also confirmed on bitcoin. - pub fn submit_sweep_tx(&mut self, rpc: &Client, faucet: &Faucet) { + /// sweep transaction, it broadcasts this transaction to the bitcoin + /// network. + pub fn broadcast_sweep_tx(&mut self, rpc: &Client) { // Okay now we try to peg-in the deposit by making a transaction. // Let's start by getting the signer's sole UTXO. let aggregated_signer = &self.signers.signer; let signer_utxo = aggregated_signer.get_utxos(rpc, None).pop().unwrap(); + // Well we want a BitcoinCoreClient, so we create one using the + // settings. Not, the best thing to do, sorry. TODO: pass in a + // bitcoin core client object. + let settings = Settings::new_from_default_config().unwrap(); + let btc = BitcoinCoreClient::try_from(&settings.bitcoin.rpc_endpoints[0]).unwrap(); + let outpoint = OutPoint::new(signer_utxo.txid, signer_utxo.vout); + let txids = btc.get_tx_spending_prevout(&outpoint).unwrap(); + + let last_fees = txids + .iter() + .filter_map(|txid| btc.get_mempool_entry(txid).unwrap()) + .map(|entry| Fees { + total: entry.fees.base.to_sat(), + rate: entry.fees.base.to_sat() as f64 / entry.vsize as f64, + }) + .max_by_key(|fees| fees.total); + let withdrawals = self .withdrawals .iter() @@ -875,7 +907,7 @@ impl TestSweepSetup2 { }, fee_rate: 10.0, public_key: aggregated_signer.keypair.x_only_public_key().0, - last_fees: None, + last_fees, magic_bytes: [b'T', b'3'], }, accept_threshold: 4, @@ -898,7 +930,24 @@ impl TestSweepSetup2 { unsigned.tx.compute_txid() }; - // Let's sweep in the transaction + let block_header = rpc.get_blockchain_info().unwrap(); + + self.broadcast_info = Some(BroadcastSweepTxInfo { + block_hash: block_header.best_block_hash, + txid, + }); + } + + /// This function generates a sweep transaction that sweeps in the + /// deposited funds and sweeps out the withdrawal funds in a proper + /// sweep transaction, that is also confirmed on bitcoin. + pub fn submit_sweep_tx(&mut self, rpc: &Client, faucet: &Faucet) { + if self.broadcast_info.is_none() { + self.broadcast_sweep_tx(rpc); + } + let txid = self.broadcast_info.as_ref().unwrap().txid; + + // Let's confirm the sweep transaction let block_hash = faucet.generate_blocks(1).pop().unwrap(); let block_header = rpc.get_block_header_info(&block_hash).unwrap(); @@ -934,6 +983,45 @@ impl TestSweepSetup2 { } } + /// Store the rows in the `bitcoin_tx_sighashes` for the sweep. + /// + /// This simulates the sweep transaction successfully going through + /// validation, where we write to the `bitcoin_tx_sighashes` table at + /// the end. + pub async fn store_bitcoin_tx_sighashes(&self, db: &PgStore) { + let sweep = self.broadcast_info.as_ref().expect("no sweep tx info set"); + + let sighash = BitcoinTxSigHash { + txid: sweep.txid.into(), + chain_tip: sweep.block_hash.into(), + prevout_txid: self.donation.txid.into(), + prevout_output_index: self.donation.vout, + aggregate_key: self.signers.aggregate_key().into(), + will_sign: true, + is_valid_tx: true, + validation_result: signer::bitcoin::validation::InputValidationResult::Ok, + prevout_type: model::TxPrevoutType::SignersInput, + sighash: Faker.fake_with_rng(&mut OsRng), + }; + db.write_bitcoin_txs_sighashes(&[sighash]).await.unwrap(); + + for (_, request, _) in self.deposits.iter() { + let sighash = BitcoinTxSigHash { + txid: sweep.txid.into(), + chain_tip: sweep.block_hash.into(), + prevout_txid: request.outpoint.txid.into(), + prevout_output_index: request.outpoint.vout, + aggregate_key: request.signers_public_key.into(), + will_sign: true, + is_valid_tx: true, + validation_result: signer::bitcoin::validation::InputValidationResult::Ok, + prevout_type: model::TxPrevoutType::SignersInput, + sighash: Faker.fake_with_rng(&mut OsRng), + }; + db.write_bitcoin_txs_sighashes(&[sighash]).await.unwrap(); + } + } + /// Store the rows in the `bitcoin_withdrawals_outputs` for the /// withdrawals. /// @@ -941,18 +1029,18 @@ impl TestSweepSetup2 { /// validation, where we write to the `bitcoin_withdrawals_outputs` /// table at the end. pub async fn store_bitcoin_withdrawals_outputs(&self, db: &PgStore) { - let sweep = self.sweep_tx_info.as_ref().expect("no sweep tx info set"); + let sweep = self.broadcast_info.as_ref().expect("no sweep tx info set"); for (index, withdrawal) in self.withdrawals.iter().enumerate() { let swept_output = BitcoinWithdrawalOutput { request_id: withdrawal.request.request_id, stacks_txid: withdrawal.request.txid, stacks_block_hash: withdrawal.request.block_hash, - bitcoin_chain_tip: sweep.block_hash, + bitcoin_chain_tip: sweep.block_hash.into(), is_valid_tx: true, validation_result: WithdrawalValidationResult::Ok, output_index: index as u32 + 2, - bitcoin_txid: sweep.tx_info.txid.into(), + bitcoin_txid: sweep.txid.into(), }; db.write_bitcoin_withdrawals_outputs(&[swept_output]) .await diff --git a/signer/tests/integration/transaction_coordinator.rs b/signer/tests/integration/transaction_coordinator.rs index 40d90c3d8..91e08957e 100644 --- a/signer/tests/integration/transaction_coordinator.rs +++ b/signer/tests/integration/transaction_coordinator.rs @@ -3056,6 +3056,7 @@ fn create_test_setup( deposit_block_hash, deposits: vec![(deposit_info, deposit_request, tx_info)], sweep_tx_info: None, + broadcast_info: None, donation, stacks_blocks: vec![stacks_block], signers: test_signers, diff --git a/signer/tests/integration/withdrawal_reject.rs b/signer/tests/integration/withdrawal_reject.rs index f8e1d3321..a98c434ae 100644 --- a/signer/tests/integration/withdrawal_reject.rs +++ b/signer/tests/integration/withdrawal_reject.rs @@ -19,6 +19,7 @@ use fake::Fake; use rand::SeedableRng; use signer::testing::context::*; use signer::WITHDRAWAL_BLOCKS_EXPIRY; +use signer::WITHDRAWAL_MIN_CONFIRMATIONS; use crate::setup::fetch_canonical_bitcoin_blockchain; use crate::setup::set_withdrawal_completed; @@ -530,9 +531,9 @@ async fn reject_withdrawal_validation_request_being_fulfilled() { // database with new bitcoin block headers. let chain_tip = fetch_canonical_bitcoin_blockchain(&db, rpc).await; - // Different: we need to store a row in the dkg_shares table so that we - // have a record of the scriptPubKey that the signers control. We need - // this so that the donation gets picked up correctly below. + // 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. This is + // needed for the donation. setup.store_dkg_shares(&db).await; // Normal: The signers normally have a UTXO, so we add one here too. It @@ -603,3 +604,158 @@ async fn reject_withdrawal_validation_request_being_fulfilled() { testing::storage::drop_db(db).await; } + +/// For this test we check that the `RejectWithdrawalV1::validate` function +/// returns a withdrawal validation error with a RequestStillActive message +/// when the database indicates that it is possible that the withdrawal +/// request to be unintentionally fulfilled after a bitcoin reorg. +#[tokio::test] +async fn reject_withdrawal_validation_request_still_active() { + // 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 = testing::storage::new_test_database().await; + let mut rng = rand::rngs::StdRng::seed_from_u64(51); + let (rpc, faucet) = regtest::initialize_blockchain(); + + let amount = 1_000_000; + let signers = TestSignerSet::new(&mut rng); + let amounts = [ + SweepAmounts { + amount, + max_fee: amount / 2, + is_deposit: true, + }, + SweepAmounts { + amount, + max_fee: amount / 2, + is_deposit: false, + }, + ]; + + let mut setup = TestSweepSetup2::new_setup(signers, faucet, &amounts); + + let mut ctx = TestContext::builder() + .with_storage(db.clone()) + .with_first_bitcoin_core_client() + .with_mocked_stacks_client() + .with_mocked_emily_client() + .build(); + + // Normal: the request has not been marked as completed in the smart + // contract. + set_withdrawal_incomplete(&mut ctx).await; + + // 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. + fetch_canonical_bitcoin_blockchain(&db, rpc).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. We need + // this so that the donation gets picked up correctly below. + setup.store_dkg_shares(&db).await; + + // Normal: The signers normally have a UTXO, so we add one here too. It + // is necessary when checking for whether the withdrawal being + // fulfilled by a sweep transaction that is in the mempool. + setup.store_donation(&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_requests(&db).await; + setup.store_withdrawal_decisions(&db).await; + + // Normal: We do not reject a withdrawal requests until more than + // WITHDRAWAL_BLOCKS_EXPIRY blocks have been observed since the smart + // contract that created the withdrawal request has bene observed. + faucet.generate_blocks(WITHDRAWAL_BLOCKS_EXPIRY + 1); + + // 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. + fetch_canonical_bitcoin_blockchain(&db, rpc).await; + + // Different: We broadcast a sweep transaction into the mempool so that + // the TestSweepSetup2 struct has the `broadcast_info` is set, which is + // required for `TestSweepSetup2::store_bitcoin_withdrawals_outputs`. + setup.broadcast_sweep_tx(rpc); + + // Different: We're adding rows that let the signer know that someone + // may have tried to fulfill the withdrawal request. If that + // transaction is spending the current signer UTXO, then it could + // possibly be in the mempool. Since the signers' UTXO is a donation, + // we're saying that the coordinator may have tried to fulfill the + // withdrawal. + setup.store_bitcoin_withdrawals_outputs(&db).await; + setup.store_bitcoin_tx_sighashes(&db).await; + + // Generate the transaction and corresponding request context. + let (reject_withdrawal_tx, req_ctx) = make_withdrawal_reject(&setup, &db).await; + + // Right now the withdrawal request is expired, but there is a + // transaction in the mempool that is trying to fulfill it, so + // validation must fail with RequestBeingFulfilled. After the next + // sweep transaction gets confirmed, we must observe + // WITHDRAWAL_MIN_CONFIRMATIONS more blocks. + let validation_result = reject_withdrawal_tx.validate(&ctx, &req_ctx).await; + match validation_result.unwrap_err() { + Error::WithdrawalRejectValidation(ref err) => { + assert_eq!(err.error, WithdrawalRejectErrorMsg::RequestBeingFulfilled) + } + err => panic!("unexpected error during validation {err}"), + } + + // We want to "replace" the transaction in the mempool with another + // transaction that is not fulfilling the request. If we didn't remove + // the withdrawal, RejectWithdrawalV1 would fail validation for the + // wrong reason after the sweep has been confirmed, since the + // withdrawal would be fulfilled. + // + // So we remove the withdrawals from the TestSweepSetup2 object so + // that they do not get included in the sweep transaction. + let withdrawals = setup.withdrawals.drain(..).collect::>(); + + setup.broadcast_sweep_tx(rpc); + setup.submit_sweep_tx(rpc, faucet); + setup.store_sweep_tx(&db).await; + + // This confirms the sweep in the mempool. It is the first sweep after + // trying to fulfill the withdrawal request. Now we must observe + // WITHDRAWAL_MIN_CONFIRMATIONS more blocks before the withdrawal is + // considered inactive and we can reject the withdrawal request. + faucet.generate_block(); + + // Let's add some blocks, but one shy of the number of blocks necessary + // for the withdrawal to be "inactive". + faucet.generate_blocks(WITHDRAWAL_MIN_CONFIRMATIONS - 1); + fetch_canonical_bitcoin_blockchain(&db, rpc).await; + + // We need to add back the withdrawals so that `make_withdrawal_reject` + // works. + setup.withdrawals = withdrawals; + let (reject_withdrawal_tx, req_ctx) = make_withdrawal_reject(&setup, &db).await; + + // Okay, this should fail because we haven't observed enough blocks yet. + let validation_result = reject_withdrawal_tx.validate(&ctx, &req_ctx).await; + match validation_result.unwrap_err() { + Error::WithdrawalRejectValidation(ref err) => { + assert_eq!(err.error, WithdrawalRejectErrorMsg::RequestStillActive) + } + err => panic!("unexpected error during validation {err}"), + } + + // Generate one more block. After seeing that next block, the + // withdrawal should be considered inactive. + faucet.generate_block(); + fetch_canonical_bitcoin_blockchain(&db, rpc).await; + + let (reject_withdrawal_tx, req_ctx) = make_withdrawal_reject(&setup, &db).await; + + reject_withdrawal_tx.validate(&ctx, &req_ctx).await.unwrap(); + + testing::storage::drop_db(db).await; +}