diff --git a/rs/bitcoin/ckbtc/minter/src/lib.rs b/rs/bitcoin/ckbtc/minter/src/lib.rs index 38e53f28a446..f02389e6e67d 100644 --- a/rs/bitcoin/ckbtc/minter/src/lib.rs +++ b/rs/bitcoin/ckbtc/minter/src/lib.rs @@ -622,6 +622,26 @@ fn finalized_txids(candidates: &[state::SubmittedBtcTransaction], new_utxos: &[U .collect() } +/// Returns the subset of `new_utxos` that match a change output of one of the +/// minter's submitted or stuck transactions. UTXOs sent to the minter's main +/// address by a third party are dropped, since they did not go through deposit +/// screening (minimum amount, check fee, and Bitcoin checker). +fn retain_change_utxos( + submitted_transactions: &[state::SubmittedBtcTransaction], + stuck_transactions: &[state::SubmittedBtcTransaction], + new_utxos: Vec, +) -> Vec { + let change_outpoints: BTreeSet<(Txid, u32)> = submitted_transactions + .iter() + .chain(stuck_transactions.iter()) + .filter_map(|tx| tx.change_output.as_ref().map(|out| (tx.txid, out.vout))) + .collect(); + new_utxos + .into_iter() + .filter(|utxo| change_outpoints.contains(&(utxo.outpoint.txid, utxo.outpoint.vout))) + .collect() +} + pub fn process_maybe_finalized_transactions( state: &mut state::CkBtcMinterState, maybe_finalized_transactions: &mut BTreeMap, @@ -638,8 +658,17 @@ pub fn process_maybe_finalized_transactions( // meantime. If that happens, we should stop waiting for replacement transactions to finalize. let unstuck_transactions: Vec<_> = finalized_txids(&state.stuck_transactions, &new_utxos); - if !new_utxos.is_empty() { - state::audit::add_utxos(state, None, main_account, new_utxos, runtime); + // Only import UTXOs that correspond to a change output of a transaction + // submitted by the minter. UTXOs sent to the main address by a third party + // did not go through deposit screening and must not enter the reserve. + let change_utxos = retain_change_utxos( + &state.submitted_transactions, + &state.stuck_transactions, + new_utxos, + ); + + if !change_utxos.is_empty() { + state::audit::add_utxos(state, None, main_account, change_utxos, runtime); } for txid in &confirmed_transactions { confirm_transaction(state, txid, runtime); diff --git a/rs/bitcoin/ckbtc/minter/src/tests.rs b/rs/bitcoin/ckbtc/minter/src/tests.rs index de1041f5d89c..19861dcdef21 100644 --- a/rs/bitcoin/ckbtc/minter/src/tests.rs +++ b/rs/bitcoin/ckbtc/minter/src/tests.rs @@ -1448,3 +1448,96 @@ mod submit_pending_requests { mutate_state(|s| s.ecdsa_public_key = Some(ecdsa_public_key())) } } + +mod process_maybe_finalized_transactions { + use crate::process_maybe_finalized_transactions; + use crate::state::eventlog::CkBtcEventLogger; + use crate::state::{ + ChangeOutput, RetrieveBtcRequest, SubmittedBtcTransaction, mutate_state, read_state, + }; + use crate::test_fixtures::mock::{MockCanisterRuntime, mock_increasing_time}; + use crate::test_fixtures::{NOW, ignored_utxo, init_args, init_state, minter, utxo}; + use icrc_ledger_types::icrc1::account::Account; + use std::collections::BTreeMap; + use std::time::Duration; + + #[test] + fn should_not_import_utxos_sent_by_third_parties_to_the_main_address() { + init_state(init_args()); + + let main_account = Account { + owner: minter(), + subaccount: None, + }; + + // A transaction submitted by the minter whose change output is the + // UTXO returned by `utxo()`. + let change_utxo = utxo(); + let submitted_tx = SubmittedBtcTransaction { + requests: vec![retrieve_btc_request()].into(), + txid: change_utxo.outpoint.txid, + used_utxos: vec![], + submitted_at: 0, + change_output: Some(ChangeOutput { + vout: change_utxo.outpoint.vout, + value: change_utxo.value, + }), + effective_fee_per_vbyte: None, + withdrawal_fee: None, + signed_tx: None, + }; + mutate_state(|s| s.submitted_transactions.push(submitted_tx.clone())); + + // A UTXO sent to the minter's main address by a third party. It does not + // correspond to any change output of a minter transaction and was never + // screened by the Bitcoin checker. + let third_party_utxo = ignored_utxo(); + + let mut runtime = MockCanisterRuntime::new(); + mock_increasing_time(&mut runtime, NOW, Duration::from_secs(1)); + runtime.expect_event_logger().return_const(CkBtcEventLogger); + + let mut maybe_finalized_transactions = + BTreeMap::from([(submitted_tx.txid, submitted_tx.clone())]); + + mutate_state(|s| { + process_maybe_finalized_transactions( + s, + &mut maybe_finalized_transactions, + vec![change_utxo.clone(), third_party_utxo.clone()], + main_account, + &runtime, + ) + }); + + read_state(|s| { + assert!( + s.available_utxos.contains(&change_utxo), + "the minter's own change output must enter the reserve" + ); + assert!( + !s.available_utxos.contains(&third_party_utxo), + "unscreened third-party UTXO must not enter the reserve" + ); + assert_eq!( + s.tokens_minted, change_utxo.value, + "only the screened change output must be accounted for" + ); + }); + + // The submitted transaction is finalized once its change output appears. + assert!(maybe_finalized_transactions.is_empty()); + read_state(|s| assert!(s.submitted_transactions.is_empty())); + } + + fn retrieve_btc_request() -> RetrieveBtcRequest { + RetrieveBtcRequest { + amount: 10_000, + address: crate::test_fixtures::bitcoin_address(), + block_index: 0, + received_at: 0, + kyt_provider: None, + reimbursement_account: None, + } + } +}