diff --git a/rs/bitcoin/ckbtc/minter/src/storage/tests.rs b/rs/bitcoin/ckbtc/minter/src/storage/tests.rs index 906a1e76f3e..453f44c5cb6 100644 --- a/rs/bitcoin/ckbtc/minter/src/storage/tests.rs +++ b/rs/bitcoin/ckbtc/minter/src/storage/tests.rs @@ -1,48 +1,38 @@ -use crate::state::eventlog::{Event, EventType}; -use crate::test_fixtures::{ignored_utxo, ledger_account}; - -#[test] -fn should_decode_encoded_event() { - let event = Event { - timestamp: Some(123), - payload: event_type(), - }; - - let encoded = super::encode_event(&event); - let decoded = super::decode_event(&encoded); - - assert_eq!(event, decoded); -} - -#[test] -fn should_decode_encoded_legacy_event() { - /// Legacy events simply consisted of an event type instance. The - /// encoding logic is the exact same as for new events, only the type - /// being encoded differs. - fn encode_legacy_event(event: &EventType) -> Vec { - let mut buf = Vec::new(); - ciborium::ser::into_writer(event, &mut buf).expect("failed to encode a minter event"); - buf +use super::{decode_event, encode_event}; +use crate::{ + state::eventlog::{Event, EventType}, + test_fixtures::arbitrary, +}; +use proptest::proptest; + +proptest! { + #[test] + fn should_decode_encoded_event(event in arbitrary::event()) { + let encoded = encode_event(&event); + let decoded = decode_event(&encoded); + + assert_eq!(event, decoded); } - let legacy_event = event_type(); - - let encoded = encode_legacy_event(&legacy_event); - let decoded = super::decode_event(&encoded); - - assert_eq!( - decoded, - Event { - timestamp: None, - payload: legacy_event, + #[test] + fn should_decode_encoded_legacy_event(legacy_event in arbitrary::event_type()) { + /// Legacy events just consist of an event type instance. The encoding logic + /// is the exact same as for new events. Only the type being encoded differs. + fn encode_legacy_event(event: &EventType) -> Vec { + let mut buf = Vec::new(); + ciborium::ser::into_writer(event, &mut buf).expect("failed to encode a minter event"); + buf } - ); -} -fn event_type() -> EventType { - EventType::ReceivedUtxos { - mint_txid: Some(1), - to_account: ledger_account(), - utxos: vec![ignored_utxo()], + let encoded = encode_legacy_event(&legacy_event); + let decoded = decode_event(&encoded); + + assert_eq!( + decoded, + Event { + timestamp: None, + payload: legacy_event, + } + ); } } diff --git a/rs/bitcoin/ckbtc/minter/src/test_fixtures.rs b/rs/bitcoin/ckbtc/minter/src/test_fixtures.rs index cee6585f3a8..7861fc0c5c3 100644 --- a/rs/bitcoin/ckbtc/minter/src/test_fixtures.rs +++ b/rs/bitcoin/ckbtc/minter/src/test_fixtures.rs @@ -142,3 +142,306 @@ pub mod mock { } } } + +pub mod arbitrary { + use crate::{ + address::BitcoinAddress, + signature::EncodedSignature, + state::{ + eventlog::{Event, EventType}, + ChangeOutput, Mode, ReimbursementReason, RetrieveBtcRequest, SuspendedReason, + }, + tx, + tx::{SignedInput, TxOut, UnsignedInput}, + }; + use candid::Principal; + pub use event::event_type; + use ic_base_types::CanisterId; + use ic_btc_interface::{OutPoint, Satoshi, Txid, Utxo}; + use icrc_ledger_types::icrc1::account::Account; + use proptest::{ + array::uniform20, + array::uniform32, + collection::{vec as pvec, SizeRange}, + option, + prelude::{any, Just, Strategy}, + prop_oneof, + }; + use serde_bytes::ByteBuf; + + // Macro to simplify writing strategies that generate structs. + macro_rules! prop_struct { + ($struct_path:path { $($field_name:ident: $strategy:expr),* $(,)? }) => { + #[allow(unused_parens)] + ($($strategy),*).prop_map(|($($field_name),*)| { + $struct_path { + $($field_name),* + } + }) + }; + } + + fn amount() -> impl Strategy { + 1..10_000_000_000u64 + } + + fn txid() -> impl Strategy { + uniform32(any::()).prop_map(Txid::from) + } + + fn outpoint() -> impl Strategy { + prop_struct!(OutPoint { + txid: txid(), + vout: any::(), + }) + } + + fn canister_id() -> impl Strategy { + any::().prop_map(CanisterId::from_u64) + } + + pub fn retrieve_btc_requests( + amount: impl Strategy, + num: impl Into, + ) -> impl Strategy> { + pvec(retrieve_btc_request(amount), num).prop_map(|mut reqs| { + reqs.sort_by_key(|req| req.received_at); + for (i, req) in reqs.iter_mut().enumerate() { + req.block_index = i as u64; + } + reqs + }) + } + + fn principal() -> impl Strategy { + pvec(any::(), 1..=Principal::MAX_LENGTH_IN_BYTES) + .prop_map(|bytes| Principal::from_slice(bytes.as_slice())) + } + + fn retrieve_btc_request( + amount: impl Strategy, + ) -> impl Strategy { + prop_struct!(RetrieveBtcRequest { + amount: amount, + address: address(), + block_index: any::(), + received_at: 1569975147000..2069975147000u64, + kyt_provider: option::of(principal()), + reimbursement_account: option::of(account()), + }) + } + + fn reimbursement_reason() -> impl Strategy { + prop_oneof![ + (principal(), any::()).prop_map(|(kyt_provider, kyt_fee)| { + ReimbursementReason::TaintedDestination { + kyt_provider, + kyt_fee, + } + }), + Just(ReimbursementReason::CallFailed), + ] + } + + fn suspended_reason() -> impl Strategy { + prop_oneof![ + Just(SuspendedReason::ValueTooSmall), + Just(SuspendedReason::Quarantined), + ] + } + + fn change_output() -> impl Strategy { + (any::(), any::()).prop_map(|(vout, value)| ChangeOutput { vout, value }) + } + + fn mode() -> impl Strategy { + prop_oneof![ + Just(Mode::ReadOnly), + pvec(principal(), 0..10_000).prop_map(Mode::RestrictedTo), + pvec(principal(), 0..10_000).prop_map(Mode::DepositsRestrictedTo), + Just(Mode::GeneralAvailability), + ] + } + + fn encoded_signature() -> impl Strategy { + pvec(1u8..0xff, 64).prop_map(|bytes| EncodedSignature::from_sec1(bytes.as_slice())) + } + + pub fn unsigned_input( + value: impl Strategy, + ) -> impl Strategy { + prop_struct!(UnsignedInput { + previous_output: outpoint(), + value: value, + sequence: any::(), + }) + } + + pub fn signed_input() -> impl Strategy { + prop_struct!(SignedInput { + previous_output: outpoint(), + sequence: any::(), + signature: encoded_signature(), + pubkey: pvec(any::(), tx::PUBKEY_LEN).prop_map(ByteBuf::from), + }) + } + + pub fn address() -> impl Strategy { + prop_oneof![ + uniform20(any::()).prop_map(BitcoinAddress::P2wpkhV0), + uniform32(any::()).prop_map(BitcoinAddress::P2wshV0), + uniform32(any::()).prop_map(BitcoinAddress::P2trV1), + uniform20(any::()).prop_map(BitcoinAddress::P2pkh), + uniform20(any::()).prop_map(BitcoinAddress::P2sh), + ] + } + + pub fn tx_out() -> impl Strategy { + prop_struct!(TxOut { + value: amount(), + address: address(), + }) + } + + pub fn utxo(amount: impl Strategy) -> impl Strategy { + prop_struct!(Utxo { + outpoint: outpoint(), + value: amount, + height: any::(), + }) + } + + pub fn account() -> impl Strategy { + prop_struct!(Account { + owner: principal(), + subaccount: option::of(uniform32(any::())), + }) + } + + pub fn event() -> impl Strategy { + (any::>(), event_type()) + .prop_map(|(timestamp, payload)| Event { timestamp, payload }) + } + + // Some event types are deprecated, however we still want to use them in prop tests as we want + // to make sure they can still be deserialized. + // For convenience, the module is not visible to the outside. + #[allow(deprecated)] + mod event { + use super::*; + use crate::lifecycle::{ + init::{BtcNetwork, InitArgs}, + upgrade::UpgradeArgs, + }; + + fn btc_network() -> impl Strategy { + prop_oneof![ + Just(BtcNetwork::Mainnet), + Just(BtcNetwork::Testnet), + Just(BtcNetwork::Regtest), + ] + } + + fn init_args() -> impl Strategy { + prop_struct!(InitArgs { + btc_network: btc_network(), + ecdsa_key_name: ".*", + retrieve_btc_min_amount: any::(), + ledger_id: canister_id(), + max_time_in_queue_nanos: any::(), + min_confirmations: option::of(any::()), + mode: mode(), + check_fee: option::of(any::()), + kyt_fee: option::of(any::()), + btc_checker_principal: option::of(canister_id()), + kyt_principal: option::of(canister_id()), + }) + } + + fn upgrade_args() -> impl Strategy { + prop_struct!(UpgradeArgs { + retrieve_btc_min_amount: option::of(any::()), + min_confirmations: option::of(any::()), + max_time_in_queue_nanos: option::of(any::()), + mode: option::of(mode()), + check_fee: option::of(any::()), + kyt_fee: option::of(any::()), + btc_checker_principal: option::of(canister_id()), + kyt_principal: option::of(canister_id()), + }) + } + + pub fn event_type() -> impl Strategy { + prop_oneof![ + init_args().prop_map(EventType::Init), + upgrade_args().prop_map(EventType::Upgrade), + retrieve_btc_request(amount()).prop_map(EventType::AcceptedRetrieveBtcRequest), + prop_struct!(EventType::ReceivedUtxos { + mint_txid: option::of(any::()), + to_account: account(), + utxos: pvec(utxo(amount()), 0..10_000), + }), + prop_struct!(EventType::RemovedRetrieveBtcRequest { + block_index: any::() + }), + prop_struct!(EventType::SentBtcTransaction { + request_block_indices: pvec(any::(), 0..10_000), + txid: txid(), + utxos: pvec(utxo(amount()), 0..10_000), + change_output: option::of(change_output()), + submitted_at: any::(), + fee_per_vbyte: option::of(any::()), + }), + prop_struct!(EventType::ReplacedBtcTransaction { + old_txid: txid(), + new_txid: txid(), + change_output: change_output(), + submitted_at: any::(), + fee_per_vbyte: any::(), + }), + prop_struct!(EventType::ConfirmedBtcTransaction { txid: txid() }), + prop_struct!(EventType::CheckedUtxo { + utxo: utxo(amount()), + uuid: any::(), + clean: any::(), + kyt_provider: option::of(principal()) + }), + prop_struct!(EventType::CheckedUtxoV2 { + utxo: utxo(amount()), + account: account(), + }), + prop_struct!(EventType::IgnoredUtxo { + utxo: utxo(amount()) + }), + prop_struct!(EventType::SuspendedUtxo { + utxo: utxo(amount()), + account: account(), + reason: suspended_reason(), + }), + prop_struct!(EventType::DistributedKytFee { + kyt_provider: principal(), + amount: any::(), + block_index: any::(), + }), + prop_struct!(EventType::RetrieveBtcKytFailed { + owner: principal(), + address: ".*", + amount: any::(), + uuid: ".*", + kyt_provider: principal(), + block_index: any::(), + }), + prop_struct!(EventType::ScheduleDepositReimbursement { + account: account(), + amount: any::(), + reason: reimbursement_reason(), + burn_block_index: any::(), + }), + prop_struct!(EventType::ReimbursedFailedDeposit { + burn_block_index: any::(), + mint_block_index: any::(), + }), + ] + } + } +} diff --git a/rs/bitcoin/ckbtc/minter/src/tests.rs b/rs/bitcoin/ckbtc/minter/src/tests.rs index e492584e597..b772c95e58b 100644 --- a/rs/bitcoin/ckbtc/minter/src/tests.rs +++ b/rs/bitcoin/ckbtc/minter/src/tests.rs @@ -1,33 +1,29 @@ -use crate::state::invariants::CheckInvariantsImpl; -use crate::{ - address::BitcoinAddress, build_unsigned_transaction, estimate_retrieve_btc_fee, fake_sign, - greedy, signature::EncodedSignature, tx, BuildTxError, -}; -use crate::{evaluate_minter_fee, MINTER_ADDRESS_DUST_LIMIT}; use crate::{ + address::BitcoinAddress, + build_unsigned_transaction, estimate_retrieve_btc_fee, evaluate_minter_fee, fake_sign, greedy, lifecycle::init::InitArgs, + state::invariants::CheckInvariantsImpl, state::{ ChangeOutput, CkBtcMinterState, Mode, RetrieveBtcRequest, RetrieveBtcStatus, SubmittedBtcTransaction, }, + test_fixtures::arbitrary, + tx, BuildTxError, MINTER_ADDRESS_DUST_LIMIT, }; use bitcoin::network::constants::Network as BtcNetwork; use bitcoin::util::psbt::serialize::{Deserialize, Serialize}; use candid::Principal; -use ic_base_types::{CanisterId, PrincipalId}; -use ic_btc_interface::{Network, OutPoint, Satoshi, Txid, Utxo}; +use ic_base_types::CanisterId; +use ic_btc_interface::{Network, OutPoint, Utxo}; use icrc_ledger_types::icrc1::account::Account; use maplit::btreeset; -use proptest::proptest; use proptest::{ array::uniform20, - array::uniform32, - collection::{btree_set, vec as pvec, SizeRange}, + collection::{btree_set, vec as pvec}, option, - prelude::{any, Strategy}, + prelude::any, + prop_assert, prop_assert_eq, prop_assume, proptest, }; -use proptest::{prop_assert, prop_assert_eq, prop_assume, prop_oneof}; -use serde_bytes::ByteBuf; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::str::FromStr; @@ -444,121 +440,6 @@ fn test_no_dust_in_change_output() { } } -fn arb_amount() -> impl Strategy { - 1..10_000_000_000u64 -} - -fn vec_to_txid(vec: Vec) -> Txid { - let bytes: [u8; 32] = vec.try_into().expect("Can't convert to [u8; 32]"); - bytes.into() -} - -fn arb_out_point() -> impl Strategy { - (pvec(any::(), 32), any::()).prop_map(|(txid, vout)| tx::OutPoint { - txid: vec_to_txid(txid), - vout, - }) -} - -fn arb_unsigned_input( - value: impl Strategy, -) -> impl Strategy { - (arb_out_point(), value, any::()).prop_map(|(previous_output, value, sequence)| { - tx::UnsignedInput { - previous_output, - value, - sequence, - } - }) -} - -fn arb_signed_input() -> impl Strategy { - ( - arb_out_point(), - any::(), - pvec(1u8..0xff, 64), - pvec(any::(), 32), - ) - .prop_map( - |(previous_output, sequence, sec1, pubkey)| tx::SignedInput { - previous_output, - sequence, - signature: EncodedSignature::from_sec1(&sec1), - pubkey: ByteBuf::from(pubkey), - }, - ) -} - -fn arb_address() -> impl Strategy { - prop_oneof![ - uniform20(any::()).prop_map(BitcoinAddress::P2wpkhV0), - uniform32(any::()).prop_map(BitcoinAddress::P2wshV0), - uniform32(any::()).prop_map(BitcoinAddress::P2trV1), - uniform20(any::()).prop_map(BitcoinAddress::P2pkh), - uniform20(any::()).prop_map(BitcoinAddress::P2sh), - ] -} - -fn arb_tx_out() -> impl Strategy { - (arb_amount(), arb_address()).prop_map(|(value, address)| tx::TxOut { value, address }) -} - -fn arb_utxo(amount: impl Strategy) -> impl Strategy { - (amount, pvec(any::(), 32), 0..5u32).prop_map(|(value, txid, vout)| Utxo { - outpoint: OutPoint { - txid: vec_to_txid(txid), - vout, - }, - value, - height: 0, - }) -} - -fn arb_account() -> impl Strategy { - (pvec(any::(), 32), option::of(uniform32(any::()))).prop_map(|(pk, subaccount)| { - Account { - owner: PrincipalId::new_self_authenticating(&pk).0, - subaccount, - } - }) -} - -fn arb_retrieve_btc_requests( - amount: impl Strategy, - num: impl Into, -) -> impl Strategy> { - let request_strategy = ( - amount, - arb_address(), - any::(), - 1569975147000..2069975147000u64, - option::of(any::()), - option::of(arb_account()), - ) - .prop_map( - |(amount, address, block_index, received_at, provider, reimbursement_account)| { - RetrieveBtcRequest { - amount, - address, - block_index, - received_at, - kyt_provider: provider - .map(|id| Principal::from(CanisterId::from_u64(id).get())), - reimbursement_account, - } - }, - ); - pvec(request_strategy, num).prop_map(|mut reqs| { - reqs.sort_by_key(|req| req.received_at); - - for (i, req) in reqs.iter_mut().enumerate() { - req.block_index = i as u64; - } - - reqs - }) -} - proptest! { #[test] fn greedy_solution_properties( @@ -621,8 +502,8 @@ proptest! { #[test] fn unsigned_tx_encoding_model( - inputs in pvec(arb_unsigned_input(5_000u64..1_000_000_000), 1..20), - outputs in pvec(arb_tx_out(), 1..20), + inputs in pvec(arbitrary::unsigned_input(5_000u64..1_000_000_000), 1..20), + outputs in pvec(arbitrary::tx_out(), 1..20), lock_time in any::(), ) { let arb_tx = tx::UnsignedTransaction { inputs, outputs, lock_time }; @@ -643,13 +524,13 @@ proptest! { fn unsigned_tx_sighash_model( inputs_data in pvec( ( - arb_utxo(5_000u64..1_000_000_000), + arbitrary::utxo(5_000u64..1_000_000_000), any::(), pvec(any::(), tx::PUBKEY_LEN) ), 1..20 ), - outputs in pvec(arb_tx_out(), 1..20), + outputs in pvec(arbitrary::tx_out(), 1..20), lock_time in any::(), ) { let inputs: Vec = inputs_data @@ -686,8 +567,8 @@ proptest! { #[test] fn signed_tx_encoding_model( - inputs in pvec(arb_signed_input(), 1..20), - outputs in pvec(arb_tx_out(), 1..20), + inputs in pvec(arbitrary::signed_input(), 1..20), + outputs in pvec(arbitrary::tx_out(), 1..20), lock_time in any::(), ) { let arb_tx = tx::SignedTransaction { inputs, outputs, lock_time }; @@ -707,7 +588,7 @@ proptest! { #[test] fn build_tx_splits_utxos( - mut utxos in btree_set(arb_utxo(5_000u64..1_000_000_000), 1..20), + mut utxos in btree_set(arbitrary::utxo(5_000u64..1_000_000_000), 1..20), dst_pkhash in uniform20(any::()), main_pkhash in uniform20(any::()), fee_per_vbyte in 1000..2000u64, @@ -754,7 +635,7 @@ proptest! { #[test] fn check_output_order( - mut utxos in btree_set(arb_utxo(1_000_000u64..1_000_000_000), 1..20), + mut utxos in btree_set(arbitrary::utxo(1_000_000u64..1_000_000_000), 1..20), dst_pkhash in uniform20(any::()), main_pkhash in uniform20(any::()), target in 50000..100000u64, @@ -776,7 +657,7 @@ proptest! { #[test] fn build_tx_handles_change_from_inputs( - mut utxos in btree_set(arb_utxo(1_000_000u64..1_000_000_000), 1..20), + mut utxos in btree_set(arbitrary::utxo(1_000_000u64..1_000_000_000), 1..20), dst_pkhash in uniform20(any::()), main_pkhash in uniform20(any::()), target in 50000..100000u64, @@ -824,7 +705,7 @@ proptest! { #[test] fn build_tx_does_not_modify_utxos_on_error( - mut utxos in btree_set(arb_utxo(5_000u64..1_000_000_000), 1..20), + mut utxos in btree_set(arbitrary::utxo(5_000u64..1_000_000_000), 1..20), dst_pkhash in uniform20(any::()), main_pkhash in uniform20(any::()), fee_per_vbyte in 1000..2000u64, @@ -858,8 +739,8 @@ proptest! { #[test] fn add_utxos_maintains_invariants( - utxos_acc_idx in pvec((arb_utxo(5_000u64..1_000_000_000), 0..5usize), 10..20), - accounts in pvec(arb_account(), 5), + utxos_acc_idx in pvec((arbitrary::utxo(5_000u64..1_000_000_000), 0..5usize), 10..20), + accounts in pvec(arbitrary::account(), 5), ) { let mut state = CkBtcMinterState::from(InitArgs { retrieve_btc_min_amount: 1000, @@ -873,9 +754,9 @@ proptest! { #[test] fn batching_preserves_invariants( - utxos_acc_idx in pvec((arb_utxo(5_000u64..1_000_000_000), 0..5usize), 10..20), - accounts in pvec(arb_account(), 5), - requests in arb_retrieve_btc_requests(5_000u64..1_000_000_000, 1..25), + utxos_acc_idx in pvec((arbitrary::utxo(5_000u64..1_000_000_000), 0..5usize), 10..20), + accounts in pvec(arbitrary::account(), 5), + requests in arbitrary::retrieve_btc_requests(5_000u64..1_000_000_000, 1..25), limit in 1..25usize, ) { let mut state = CkBtcMinterState::from(InitArgs { @@ -907,9 +788,9 @@ proptest! { #[test] fn tx_replacement_preserves_invariants( - accounts in pvec(arb_account(), 5), - utxos_acc_idx in pvec((arb_utxo(5_000_000u64..1_000_000_000), 0..5usize), 10..=10), - requests in arb_retrieve_btc_requests(5_000_000u64..10_000_000, 1..5), + accounts in pvec(arbitrary::account(), 5), + utxos_acc_idx in pvec((arbitrary::utxo(5_000_000u64..1_000_000_000), 0..5usize), 10..=10), + requests in arbitrary::retrieve_btc_requests(5_000_000u64..10_000_000, 1..5), main_pkhash in uniform20(any::()), resubmission_chain_length in 1..=5, ) { @@ -1029,7 +910,7 @@ proptest! { } #[test] - fn btc_address_display_model(address in arb_address()) { + fn btc_address_display_model(address in arbitrary::address()) { for network in [Network::Mainnet, Network::Testnet].iter() { let addr_str = address.display(*network); let btc_addr = address_to_btc_address(&address, *network); @@ -1038,7 +919,7 @@ proptest! { } #[test] - fn address_roundtrip(address in arb_address()) { + fn address_roundtrip(address in arbitrary::address()) { for network in [Network::Mainnet, Network::Testnet, Network::Regtest].iter() { let addr_str = address.display(*network); prop_assert_eq!(BitcoinAddress::parse(&addr_str, *network), Ok(address.clone())); @@ -1110,7 +991,7 @@ proptest! { #[test] fn test_fee_range( - utxos in btree_set(arb_utxo(5_000u64..1_000_000_000), 0..20), + utxos in btree_set(arbitrary::utxo(5_000u64..1_000_000_000), 0..20), amount in option::of(any::()), fee_per_vbyte in 2000..10000u64, ) {