From 86bd52a60476e4658fa952954e5a0b01598a888a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Fri, 2 Oct 2026 14:22:15 +0000 Subject: [PATCH 1/2] test(minter): replace the stubbed runtime with a mockall mock of inter-canister calls TestCanisterRuntime answered inter-canister calls from an ordered queue of Candid-encoded responses that ignored the arguments of a call. Back it by a mockall mock instead: every test now registers the calls it expects, stating the canister, the method, the arguments, the attached cycles and the response. A call without a matching expectation and an expectation that goes unused both fail the test. Co-Authored-By: Claude Fable 5 --- minter/src/deposit/sweep/finalize/tests.rs | 24 +- minter/src/deposit/sweep/mint/tests.rs | 161 +++--- minter/src/deposit/sweep/tests.rs | 33 +- minter/src/deposit/sweep/timer/tests.rs | 67 +-- minter/src/monitor/tests.rs | 252 ++++----- minter/src/rpc/tests.rs | 276 +++++----- minter/src/test_fixtures/mod.rs | 2 +- minter/src/test_fixtures/runtime.rs | 607 ++++++++++++++++++--- minter/src/test_fixtures/tests.rs | 88 +++ minter/src/withdraw/nonce/tests.rs | 21 +- minter/src/withdraw/tests.rs | 303 ++++++---- 11 files changed, 1192 insertions(+), 642 deletions(-) diff --git a/minter/src/deposit/sweep/finalize/tests.rs b/minter/src/deposit/sweep/finalize/tests.rs index ac90ae3d2..8c25d9e47 100644 --- a/minter/src/deposit/sweep/finalize/tests.rs +++ b/minter/src/deposit/sweep/finalize/tests.rs @@ -52,7 +52,7 @@ async fn should_ask_for_another_round_only_after_crediting_with_sweeps_left_over finalize_devnet_sweep(); let mut runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(devnet_sweep_response); + .expect_get_transaction(devnet_sweep::transaction_signature(), devnet_sweep_response); for index in 0..MAX_CONCURRENT_RPC_CALLS { let deposit_id = (devnet_sweep::DEPOSITS.len() + index) as DepositSolId; let account = account(deposit_id as usize + 1); @@ -68,7 +68,10 @@ async fn should_ask_for_another_round_only_after_crediting_with_sweeps_left_over ); succeed_transaction(sweep_signature); if index + 1 < MAX_CONCURRENT_RPC_CALLS { - runtime = runtime.add_stub_response(GetTransactionResult::Consistent(Ok(None))); + runtime = runtime.expect_get_transaction( + sweep_signature, + GetTransactionResult::Consistent(Ok(None)), + ); } } @@ -96,7 +99,11 @@ async fn should_credit_the_amount_received_by_the_main_account() { setup(); let sweep_signature = finalize_devnet_sweep(); - credit_finalized_sweeps(&runtime_returning(devnet_sweep::derived_outcome())).await; + credit_finalized_sweeps(&runtime_returning( + sweep_signature, + devnet_sweep::derived_outcome(), + )) + .await; EventsAssert::from_recorded().expect_contains_event_eq(EventType::CreditedSweep { signature: sweep_signature, @@ -158,7 +165,7 @@ async fn should_keep_deposits_finalized_until_the_outcome_can_be_read() { let events_before = EventsAssert::from_recorded(); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(response()); + .expect_get_transaction(sweep_signature, response()); credit_finalized_sweeps(&runtime).await; @@ -190,7 +197,7 @@ async fn should_quarantine_deposits_if_the_outcome_does_not_match_the_plan() { let mut outcome = devnet_sweep::derived_outcome(); devnet_sweep::set_balances(&mut outcome, devnet_sweep::minter_main_address(), 1, 0); - credit_finalized_sweeps(&runtime_returning(outcome)).await; + credit_finalized_sweeps(&runtime_returning(sweep_signature, outcome)).await; EventsAssert::from_recorded().expect_contains_event_eq(EventType::QuarantinedSweep { signature: sweep_signature, @@ -235,10 +242,13 @@ fn finalize_devnet_sweep() -> Signature { sweep_signature } -fn runtime_returning(outcome: EncodedConfirmedTransactionWithStatusMeta) -> TestCanisterRuntime { +fn runtime_returning( + sweep_signature: Signature, + outcome: EncodedConfirmedTransactionWithStatusMeta, +) -> TestCanisterRuntime { TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(transaction_response(outcome)) + .expect_get_transaction(sweep_signature, transaction_response(outcome)) } fn transaction_response( diff --git a/minter/src/deposit/sweep/mint/tests.rs b/minter/src/deposit/sweep/mint/tests.rs index 6752fc087..634126017 100644 --- a/minter/src/deposit/sweep/mint/tests.rs +++ b/minter/src/deposit/sweep/mint/tests.rs @@ -6,9 +6,10 @@ use crate::{ storage::{FailedMintReason, failed_mint_attempt_count, reset_events}, test_fixtures::{ BLOCK_INDEX, DEPOSIT_SOL_REQUIRED_CYCLES, EventsAssert, MINIMUM_DEPOSIT_AMOUNT, account, + deposit_address, flow::deposit::{DepositFlow, PendingMintFlow, SweepFlow}, init_schnorr_master_key, init_state, - runtime::TestCanisterRuntime, + runtime::{CallResponse, TestCanisterRuntime}, signature, }, }; @@ -16,7 +17,10 @@ use candid::Nat; use cksol_types::{DepositSolId, DepositSolStatus, Memo, MintMemo}; use futures::FutureExt; use ic_canister_runtime::IcError; -use icrc_ledger_types::icrc1::transfer::{BlockIndex, NumTokens, TransferArg, TransferError}; +use icrc_ledger_types::icrc1::{ + account::Account, + transfer::{BlockIndex, NumTokens, TransferArg, TransferError}, +}; use sol_rpc_types::{Lamport, MultiRpcResult}; use std::panic::AssertUnwindSafe; @@ -50,14 +54,13 @@ async fn should_return_early_if_task_already_active() { process_pending_mints(runtime.clone()).await; assert_eq!(events_before, EventsAssert::from_recorded()); - assert!(runtime.sent_update_calls().is_empty()); } #[tokio::test] async fn should_mint_pending_deposit_and_release_the_account() { setup(); let pending = credited_pending_mint(); - let runtime = mint_runtime([Ok(BLOCK_INDEX.into())]); + let runtime = mint_runtime([(pending, Ok(BLOCK_INDEX.into()))]); process_pending_mints(runtime.clone()).await; @@ -72,13 +75,9 @@ async fn should_mint_pending_deposit_and_release_the_account() { deposit_id: pending.deposit_id, mint_block_index: BLOCK_INDEX.into(), }); - assert_eq!( - transfer_args_sent_by(&runtime), - vec![expected_transfer_arg(&pending)] - ); assert_eq!(runtime.set_timer_call_count(), 0); - let new_deposit_id = deposit_sol(&deposit_sol_runtime(), pending.account).await; + let new_deposit_id = deposit_sol(&deposit_sol_runtime(pending.account), pending.account).await; assert_eq!(new_deposit_id, Ok(pending.deposit_id + 1)); } @@ -86,9 +85,12 @@ async fn should_mint_pending_deposit_and_release_the_account() { async fn should_record_duplicate_reply_as_minted() { setup(); let pending = credited_pending_mint(); - let runtime = mint_runtime([Err(TransferError::Duplicate { - duplicate_of: BlockIndex::from(BLOCK_INDEX), - })]); + let runtime = mint_runtime([( + pending, + Err(TransferError::Duplicate { + duplicate_of: BlockIndex::from(BLOCK_INDEX), + }), + )]); process_pending_mints(runtime).await; @@ -107,47 +109,40 @@ async fn should_record_duplicate_reply_as_minted() { #[tokio::test] async fn should_retry_after_transient_failure_with_exactly_the_same_arguments() { - type AddFailure = fn(TestCanisterRuntime) -> TestCanisterRuntime; - let transient_failures: [(&str, AddFailure, FailedMintReason); 4] = [ + let transient_failures: [(&str, CallResponse, FailedMintReason); 4] = [ ( "the ledger is temporarily unavailable", - |runtime| { - runtime.add_stub_response::(Err(TransferError::TemporarilyUnavailable)) - }, + CallResponse::Reply(Err(TransferError::TemporarilyUnavailable)), FailedMintReason::LedgerError, ), ( "the ledger returns a generic error", - |runtime| { - runtime.add_stub_response::(Err(TransferError::GenericError { - error_code: Nat::from(42_u8), - message: "out of luck".to_string(), - })) - }, + CallResponse::Reply(Err(TransferError::GenericError { + error_code: Nat::from(42_u8), + message: "out of luck".to_string(), + })), FailedMintReason::LedgerError, ), ( "the minter clock is ahead of the ledger", - |runtime| { - runtime.add_stub_response::(Err(TransferError::CreatedInFuture { - ledger_time: 0, - })) - }, + CallResponse::Reply(Err(TransferError::CreatedInFuture { ledger_time: 0 })), FailedMintReason::CreatedInFuture, ), ( "the call to the ledger fails", - |runtime| runtime.add_stub_error(IcError::CallPerformFailed), + CallResponse::Failed(IcError::CallPerformFailed), FailedMintReason::CallError, ), ]; let mut expected_attempts = std::collections::BTreeMap::new(); - for (name, add_failure, expected_reason) in transient_failures { + for (name, failure, expected_reason) in transient_failures { setup(); let pending = credited_pending_mint(); let events_before = EventsAssert::from_recorded(); - let failing_runtime = add_failure(TestCanisterRuntime::new().with_increasing_time()); + let failing_runtime = TestCanisterRuntime::new() + .with_increasing_time() + .expect_icrc1_transfer(expected_transfer_arg(&pending), failure); process_pending_mints(failing_runtime.clone()).await; @@ -166,7 +161,7 @@ async fn should_retry_after_transient_failure_with_exactly_the_same_arguments() ); assert_eq!(events_before, EventsAssert::from_recorded(), "{name}"); - let retrying_runtime = mint_runtime([Ok(BLOCK_INDEX.into())]); + let retrying_runtime = mint_runtime([(pending, Ok(BLOCK_INDEX.into()))]); process_pending_mints(retrying_runtime.clone()).await; @@ -178,14 +173,6 @@ async fn should_retry_after_transient_failure_with_exactly_the_same_arguments() }, "{name}" ); - let first_attempt = transfer_args_sent_by(&failing_runtime); - let retry = transfer_args_sent_by(&retrying_runtime); - assert_eq!( - first_attempt, - vec![expected_transfer_arg(&pending)], - "{name}" - ); - assert_eq!(first_attempt, retry, "{name}"); } } @@ -193,8 +180,7 @@ async fn should_retry_after_transient_failure_with_exactly_the_same_arguments() async fn should_quarantine_pending_mint() { struct QuarantineCase { name: &'static str, - runtime: TestCanisterRuntime, - expected_ledger_calls: usize, + ledger_response: Option>, traps: bool, expected_reason: FailedMintReason, } @@ -202,49 +188,43 @@ async fn should_quarantine_pending_mint() { let cases = [ QuarantineCase { name: "the pending mint is older than the deduplication window of the ledger", - runtime: TestCanisterRuntime::new().add_times([stale_now; 3]), - expected_ledger_calls: 0, + ledger_response: None, traps: false, expected_reason: FailedMintReason::Expired, }, QuarantineCase { name: "the ledger rejects the mint as too old", - runtime: mint_runtime([Err(TransferError::TooOld)]), - expected_ledger_calls: 1, + ledger_response: Some(CallResponse::Reply(Err(TransferError::TooOld))), traps: false, expected_reason: FailedMintReason::Expired, }, QuarantineCase { name: "the ledger rejects the fee of the mint", - runtime: mint_runtime([Err(TransferError::BadFee { + ledger_response: Some(CallResponse::Reply(Err(TransferError::BadFee { expected_fee: Nat::from(10_u8), - })]), - expected_ledger_calls: 1, + }))), traps: false, expected_reason: FailedMintReason::Rejected, }, QuarantineCase { name: "the ledger takes the mint for a burn below the minimum", - runtime: mint_runtime([Err(TransferError::BadBurn { + ledger_response: Some(CallResponse::Reply(Err(TransferError::BadBurn { min_burn_amount: Nat::from(10_u8), - })]), - expected_ledger_calls: 1, + }))), traps: false, expected_reason: FailedMintReason::Rejected, }, QuarantineCase { name: "the ledger reports insufficient funds on the minting account", - runtime: mint_runtime([Err(TransferError::InsufficientFunds { + ledger_response: Some(CallResponse::Reply(Err(TransferError::InsufficientFunds { balance: Nat::from(0_u8), - })]), - expected_ledger_calls: 1, + }))), traps: false, expected_reason: FailedMintReason::Rejected, }, QuarantineCase { name: "the callback traps after the ledger minted because mint index is not u64", - runtime: mint_runtime([Ok(Nat::from(u128::MAX))]), - expected_ledger_calls: 1, + ledger_response: Some(CallResponse::Reply(Ok(Nat::from(u128::MAX)))), traps: true, expected_reason: FailedMintReason::UnknownOutcome, }, @@ -253,25 +233,25 @@ async fn should_quarantine_pending_mint() { let mut expected_attempts = std::collections::BTreeMap::new(); for QuarantineCase { name, - runtime, - expected_ledger_calls, + ledger_response, traps, expected_reason, } in cases { setup(); let pending = credited_pending_mint(); + let runtime = match ledger_response { + None => TestCanisterRuntime::new().add_times([stale_now; 3]), + Some(response) => TestCanisterRuntime::new() + .with_increasing_time() + .expect_icrc1_transfer(expected_transfer_arg(&pending), response), + }; let outcome = AssertUnwindSafe(process_pending_mints(runtime.clone())) .catch_unwind() .await; assert_eq!(outcome.is_err(), traps, "{name}"); - assert_eq!( - transfer_args_sent_by(&runtime).len(), - expected_ledger_calls, - "{name}" - ); assert_eq!( deposit_status(pending.deposit_id), DepositSolStatus::Quarantined { @@ -298,9 +278,13 @@ async fn should_quarantine_pending_mint() { async fn should_reschedule_until_all_pending_mints_are_processed() { const NUM_DEPOSITS: usize = MAX_PENDING_MINTS_PER_ROUND + 1; setup(); - credit_sweeps_of_deposits(NUM_DEPOSITS); + let pending_mints = credit_sweeps_of_deposits(NUM_DEPOSITS); let runtime = mint_runtime( - (0..MAX_PENDING_MINTS_PER_ROUND as u64).map(|block_index| Ok(block_index.into())), + pending_mints + .iter() + .take(MAX_PENDING_MINTS_PER_ROUND) + .enumerate() + .map(|(block_index, pending)| (*pending, Ok((block_index as u64).into()))), ); process_pending_mints(runtime.clone()).await; @@ -311,7 +295,10 @@ async fn should_reschedule_until_all_pending_mints_are_processed() { }); assert_eq!(runtime.set_timer_call_count(), 1); - let runtime = mint_runtime([Ok((MAX_PENDING_MINTS_PER_ROUND as u64).into())]); + let runtime = mint_runtime([( + pending_mints[MAX_PENDING_MINTS_PER_ROUND], + Ok((MAX_PENDING_MINTS_PER_ROUND as u64).into()), + )]); process_pending_mints(runtime.clone()).await; @@ -326,9 +313,12 @@ async fn should_reschedule_until_all_pending_mints_are_processed() { async fn should_not_reschedule_after_a_round_of_transient_failures() { const NUM_DEPOSITS: usize = MAX_PENDING_MINTS_PER_ROUND + 1; setup(); - credit_sweeps_of_deposits(NUM_DEPOSITS); + let pending_mints = credit_sweeps_of_deposits(NUM_DEPOSITS); let runtime = mint_runtime( - (0..MAX_PENDING_MINTS_PER_ROUND).map(|_| Err(TransferError::TemporarilyUnavailable)), + pending_mints + .iter() + .take(MAX_PENDING_MINTS_PER_ROUND) + .map(|pending| (*pending, Err(TransferError::TemporarilyUnavailable))), ); process_pending_mints(runtime.clone()).await; @@ -337,10 +327,6 @@ async fn should_not_reschedule_after_a_round_of_transient_failures() { assert_eq!(s.deposits().pending_mints().len(), NUM_DEPOSITS); assert!(s.deposits().minted().is_empty()); }); - assert_eq!( - transfer_args_sent_by(&runtime).len(), - MAX_PENDING_MINTS_PER_ROUND - ); assert_eq!(runtime.set_timer_call_count(), 0); } @@ -376,23 +362,27 @@ fn credit_sweeps_of_deposits(num_deposits: usize) -> Vec { .collect() } -fn mint_runtime>(results: I) -> TestCanisterRuntime { +fn mint_runtime(mints: I) -> TestCanisterRuntime +where + I: IntoIterator, +{ let mut runtime = TestCanisterRuntime::new().with_increasing_time(); - for result in results { - runtime = runtime.add_stub_response(result); + for (pending, result) in mints { + runtime = runtime.expect_icrc1_transfer(expected_transfer_arg(&pending), result); } runtime } -fn deposit_sol_runtime() -> TestCanisterRuntime { +fn deposit_sol_runtime(depositor: Account) -> TestCanisterRuntime { TestCanisterRuntime::new() .with_increasing_time() .expecting_charges() .add_msg_cycles_available(DEPOSIT_SOL_REQUIRED_CYCLES) .add_msg_cycles_refunded(GET_BALANCE_CYCLES / 2) - .add_stub_response(MultiRpcResult::::Consistent(Ok( - MINIMUM_DEPOSIT_AMOUNT, - ))) + .expect_get_balance( + deposit_address(depositor), + MultiRpcResult::Consistent(Ok(MINIMUM_DEPOSIT_AMOUNT)), + ) } fn expected_transfer_arg(pending: &PendingMintFlow) -> TransferArg { @@ -406,17 +396,6 @@ fn expected_transfer_arg(pending: &PendingMintFlow) -> TransferArg { } } -fn transfer_args_sent_by(runtime: &TestCanisterRuntime) -> Vec { - runtime - .sent_update_calls() - .iter() - .map(|call| { - assert_eq!(call.method, "icrc1_transfer"); - call.single_arg() - }) - .collect() -} - fn deposit_status(deposit_id: DepositSolId) -> DepositSolStatus { read_state(|s| s.deposits().status(deposit_id)) } diff --git a/minter/src/deposit/sweep/tests.rs b/minter/src/deposit/sweep/tests.rs index 553610a6c..9cd81f465 100644 --- a/minter/src/deposit/sweep/tests.rs +++ b/minter/src/deposit/sweep/tests.rs @@ -5,8 +5,9 @@ use crate::{ storage::with_event_iter, test_fixtures::{ DEPOSIT_SOL_FEE, DEPOSIT_SOL_REQUIRED_CYCLES, EventsAssert, MINIMUM_DEPOSIT_AMOUNT, - MINTER_ACCOUNT, account, deposit::DEPOSITOR_ACCOUNT, events, init_schnorr_master_key, - init_state, queued_deposit_of, runtime::TestCanisterRuntime, signature, + MINTER_ACCOUNT, account, deposit::DEPOSITOR_ACCOUNT, deposit_address, events, + init_schnorr_master_key, init_state, queued_deposit_of, runtime::TestCanisterRuntime, + signature, }, }; use assert_matches::assert_matches; @@ -70,10 +71,13 @@ async fn should_fail_while_the_minter_public_key_is_unavailable() { async fn should_fail_and_charge_balance_read_if_get_balance_is_rejected() { init_state(); init_schnorr_master_key(); - let runtime = runtime().add_stub_error(IcError::CallRejected { - code: RejectCode::SysTransient, - message: "SOL RPC canister is stopped".to_string(), - }); + let runtime = runtime().expect_get_balance( + deposit_address(DEPOSITOR_ACCOUNT), + IcError::CallRejected { + code: RejectCode::SysTransient, + message: "SOL RPC canister is stopped".to_string(), + }, + ); let result = deposit_sol(&runtime, DEPOSITOR_ACCOUNT).await; @@ -92,7 +96,7 @@ async fn should_fail_and_charge_balance_read_if_get_balance_is_rejected() { async fn should_fail_and_charge_balance_read_if_balance_below_minimum() { init_state(); init_schnorr_master_key(); - let runtime = runtime().add_get_balance_response(MINIMUM_DEPOSIT_AMOUNT - 1); + let runtime = runtime().add_get_balance_response(DEPOSITOR_ACCOUNT, MINIMUM_DEPOSIT_AMOUNT - 1); let result = deposit_sol(&runtime, DEPOSITOR_ACCOUNT).await; @@ -120,7 +124,7 @@ async fn should_queue_deposits_from_minimum_with_sequential_ids() { (0, DEPOSITOR_ACCOUNT, MINIMUM_DEPOSIT_AMOUNT), (1, other_account, MINIMUM_DEPOSIT_AMOUNT + 1), ] { - let runtime = runtime().add_get_balance_response(balance); + let runtime = runtime().add_get_balance_response(depositor, balance); let deposit_id = deposit_sol(&runtime, depositor).await; @@ -204,7 +208,7 @@ async fn assert_second_call_returns_same_deposit(first: Account, second: Account init_state(); init_schnorr_master_key(); let deposit_id = deposit_sol( - &runtime().add_get_balance_response(MINIMUM_DEPOSIT_AMOUNT), + &runtime().add_get_balance_response(first, MINIMUM_DEPOSIT_AMOUNT), first, ) .await @@ -244,7 +248,7 @@ fn queued_deposit_event(deposit_id: u64, account: Account, sweepable_amount: Lam } /// Runtime for a `deposit_sol` call that makes a `getBalance` call -/// whose stub response or error the caller chains. +/// whose expected response or error the caller chains. fn runtime() -> TestCanisterRuntime { TestCanisterRuntime::new() .with_increasing_time() @@ -254,11 +258,14 @@ fn runtime() -> TestCanisterRuntime { } trait GetBalanceRuntimeExt: Sized { - fn add_get_balance_response(self, balance: Lamport) -> Self; + fn add_get_balance_response(self, depositor: Account, balance: Lamport) -> Self; } impl GetBalanceRuntimeExt for TestCanisterRuntime { - fn add_get_balance_response(self, balance: Lamport) -> Self { - self.add_stub_response(MultiRpcResult::::Consistent(Ok(balance))) + fn add_get_balance_response(self, depositor: Account, balance: Lamport) -> Self { + self.expect_get_balance( + deposit_address(depositor), + MultiRpcResult::Consistent(Ok(balance)), + ) } } diff --git a/minter/src/deposit/sweep/timer/tests.rs b/minter/src/deposit/sweep/timer/tests.rs index e637c1067..948ddf9e0 100644 --- a/minter/src/deposit/sweep/timer/tests.rs +++ b/minter/src/deposit/sweep/timer/tests.rs @@ -18,11 +18,7 @@ use crate::{ }; use assert_matches::assert_matches; use cksol_types::{DepositSolId, DepositSolStatus}; -use sol_rpc_types::{ - CommitmentLevel, GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetSlotRpcConfig, - Lamport, MultiRpcResult, RpcConfig, RpcError, RpcSources, SendTransactionParams, Signature, - Slot, -}; +use sol_rpc_types::{CommitmentLevel, Lamport, MultiRpcResult, RpcError, Signature, Slot}; use solana_address::Address; use solana_system_interface::instruction::SystemInstruction; @@ -83,8 +79,10 @@ async fn should_not_submit_if_fetching_blockhash_fails() { setup(); let status_before = queue_deposit(0, account(1), MINIMUM_DEPOSIT_AMOUNT); let events_before = EventsAssert::from_recorded(); - let runtime = TestCanisterRuntime::new() - .add_recent_block(Err(RpcError::ValidationError("Error".to_string()))); + let runtime = TestCanisterRuntime::new().add_recent_block( + CommitmentLevel::Confirmed, + Err(RpcError::ValidationError("Error".to_string())), + ); sweep_queued_deposits(runtime.clone()).await; @@ -107,7 +105,7 @@ async fn should_sweep_batch_with_largest_deposit_as_fee_payer() { let fee_payer_signature = account_signature(&account(2)); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_recent_block(Ok(SLOT)) + .add_recent_block(CommitmentLevel::Confirmed, Ok(SLOT)) .transaction_builder(account(2), fee_payer_signature) .add_signers([account(1), account(3)]) .build(); @@ -164,8 +162,12 @@ async fn should_record_event_even_if_transaction_submission_fails() { let fee_payer_signature = account_signature(&account(1)); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_recent_block(Ok(SLOT)) - .add_stub_response(SendTransactionResult::Inconsistent(vec![])) + .add_recent_block(CommitmentLevel::Confirmed, Ok(SLOT)) + .expect_send_transaction( + fee_payer_signature, + CommitmentLevel::Confirmed, + SendTransactionResult::Inconsistent(vec![]), + ) .add_signer(sign_for(&account(1))); sweep_queued_deposits(runtime).await; @@ -194,40 +196,15 @@ async fn should_build_and_simulate_the_sweep_at_confirmed_commitment() { queue_deposit(0, account(1), MINIMUM_DEPOSIT_AMOUNT); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_recent_block(Ok(SLOT)) - .add_stub_response(SendTransactionResult::Consistent(Ok(account_signature( - &account(1), + .add_recent_block(CommitmentLevel::Confirmed, Ok(SLOT)) + .expect_send_transaction( + account_signature(&account(1)), + CommitmentLevel::Confirmed, + SendTransactionResult::Consistent(Ok(account_signature(&account(1)).into())), ) - .into()))) .add_signer(sign_for(&account(1))); - sweep_queued_deposits(runtime.clone()).await; - - let [get_slot, get_block, send_transaction] = runtime.sent_update_calls().try_into().unwrap(); - assert_eq!(get_slot.method, "getSlot"); - let (_sources, _config, slot_params): ( - RpcSources, - Option, - Option, - ) = get_slot.args(); - assert_eq!( - slot_params.and_then(|params| params.commitment), - Some(CommitmentLevel::Confirmed) - ); - assert_eq!(get_block.method, "getBlock"); - let (_sources, _config, block_params): (RpcSources, Option, GetBlockParams) = - get_block.args(); - assert_eq!( - block_params.commitment, - Some(GetBlockCommitmentLevel::Confirmed) - ); - assert_eq!(send_transaction.method, "sendTransaction"); - let (_sources, _config, send_params): (RpcSources, Option, SendTransactionParams) = - send_transaction.args(); - assert_eq!( - send_params.preflight_commitment, - Some(CommitmentLevel::Confirmed) - ); + sweep_queued_deposits(runtime).await; } #[tokio::test] @@ -241,7 +218,7 @@ async fn should_split_deposits_into_batches_of_max_size() { let fee_payer_signature_2 = account_signature(&fee_payer_2); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_recent_block(Ok(SLOT)) + .add_recent_block(CommitmentLevel::Confirmed, Ok(SLOT)) .transaction_builder(fee_payer_1, fee_payer_signature_1) .add_signers((0..MAX_DEPOSITS_PER_SWEEP - 1).map(account)) .build() @@ -287,7 +264,7 @@ async fn should_reschedule_until_all_deposits_swept() { let swept_in_first_round = num_deposits - 1; let mut runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_recent_block(Ok(SLOT)); + .add_recent_block(CommitmentLevel::Confirmed, Ok(SLOT)); for batch in 0..MAX_CONCURRENT_RPC_CALLS { let first_in_batch = batch * MAX_DEPOSITS_PER_SWEEP; let fee_payer_index = first_in_batch + MAX_DEPOSITS_PER_SWEEP - 1; @@ -310,7 +287,7 @@ async fn should_reschedule_until_all_deposits_swept() { let last_account = account(num_deposits - 1); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_recent_block(Ok(SLOT)) + .add_recent_block(CommitmentLevel::Confirmed, Ok(SLOT)) .transaction_builder(last_account, account_signature(&last_account)) .build(); @@ -337,7 +314,7 @@ async fn should_sweep_all_batches_of_a_round_without_fetching_the_master_key() { let fee_payer_2 = account(NUM_DEPOSITS - 1); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_recent_block(Ok(SLOT)) + .add_recent_block(CommitmentLevel::Confirmed, Ok(SLOT)) .transaction_builder(fee_payer_1, account_signature(&fee_payer_1)) .add_signers((0..MAX_DEPOSITS_PER_SWEEP - 1).map(account)) .build() diff --git a/minter/src/monitor/tests.rs b/minter/src/monitor/tests.rs index fbe2d3e23..d0b1cced7 100644 --- a/minter/src/monitor/tests.rs +++ b/minter/src/monitor/tests.rs @@ -21,14 +21,11 @@ use crate::{ }, }; use sol_rpc_types::{ - CommitmentLevel, ConfirmedBlock, GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, - GetSlotRpcConfig, MultiRpcResult, RpcConfig, RpcError, RpcSources, SendTransactionParams, Slot, - TransactionConfirmationStatus, TransactionError, TransactionStatus, + CommitmentLevel, ConfirmedBlock, MultiRpcResult, RpcError, Slot, TransactionConfirmationStatus, + TransactionError, TransactionStatus, }; use solana_transaction::Transaction; -type SlotResult = MultiRpcResult; -type BlockResult = MultiRpcResult; type SignatureStatusesResult = MultiRpcResult>>; type SendTransactionResult = MultiRpcResult; type GetAccountInfoResult = MultiRpcResult>; @@ -78,12 +75,15 @@ mod finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_recent_block(Err(RpcError::ValidationError("Error".to_string()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![ - Some(finalized_status()), - None, - ]))) - .add_stub_response(GetTransactionResult::Consistent(Ok(None))); + .add_recent_block( + CommitmentLevel::Finalized, + Err(RpcError::ValidationError("Error".to_string())), + ) + .expect_get_signature_statuses( + vec![finalized, not_found], + SignatureStatusesResult::Consistent(Ok(vec![Some(finalized_status()), None])), + ) + .expect_get_transaction(finalized, GetTransactionResult::Consistent(Ok(None))); finalize_transactions(runtime).await; @@ -109,24 +109,20 @@ mod finalization { let mut runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(current_block()))); - for _ in 0..MAX_CONCURRENT_RPC_CALLS { - runtime = runtime.add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![ - None; - MAX_SIGNATURES_PER_STATUS_CHECK - ]))); + .expect_recent_block(CommitmentLevel::Finalized, CURRENT_SLOT, current_block()); + for batch in status_check_batches(num) + .into_iter() + .take(MAX_CONCURRENT_RPC_CALLS) + { + let statuses = vec![None; batch.len()]; + runtime = runtime.expect_get_signature_statuses( + batch, + SignatureStatusesResult::Consistent(Ok(statuses)), + ); } finalize_transactions(runtime.clone()).await; - assert_eq!( - called_methods(&runtime) - .iter() - .filter(|method| *method == "getSignatureStatuses") - .count(), - MAX_CONCURRENT_RPC_CALLS - ); assert_eq!( runtime.set_timer_delays(), vec![FINALIZE_TRANSACTIONS_RETRY_DELAY] @@ -141,12 +137,12 @@ mod finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(current_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![Some( - finalized_status(), - )]))) - .add_stub_response(GetTransactionResult::Consistent(Ok(None))); + .expect_recent_block(CommitmentLevel::Finalized, CURRENT_SLOT, current_block()) + .expect_get_signature_statuses( + vec![signature], + SignatureStatusesResult::Consistent(Ok(vec![Some(finalized_status())])), + ) + .expect_get_transaction(signature, GetTransactionResult::Consistent(Ok(None))); finalize_transactions(runtime).await; @@ -174,13 +170,15 @@ mod finalization { reset_events(); setup(); - submit_sweep_transaction(block_height); + let signature = submit_sweep_transaction(block_height); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(current_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![status]))); + .expect_recent_block(CommitmentLevel::Finalized, CURRENT_SLOT, current_block()) + .expect_get_signature_statuses( + vec![signature], + SignatureStatusesResult::Consistent(Ok(vec![status])), + ); let events_before = EventsAssert::from_recorded(); @@ -200,16 +198,16 @@ mod finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(current_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![Some( - TransactionStatus { + .expect_recent_block(CommitmentLevel::Finalized, CURRENT_SLOT, current_block()) + .expect_get_signature_statuses( + vec![signature], + SignatureStatusesResult::Consistent(Ok(vec![Some(TransactionStatus { slot: SUBMISSION_SLOT, status: Err(TransactionError::InsufficientFundsForFee), err: Some(TransactionError::InsufficientFundsForFee), confirmation_status: Some(TransactionConfirmationStatus::Finalized), - }, - )]))); + })])), + ); finalize_transactions(runtime).await; @@ -236,15 +234,17 @@ mod finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(current_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![ - Some(finalized_status()), - None, - Some(finalized_status()), - ]))) - .add_stub_response(GetTransactionResult::Consistent(Ok(None))) - .add_stub_response(GetTransactionResult::Consistent(Ok(None))); + .expect_recent_block(CommitmentLevel::Finalized, CURRENT_SLOT, current_block()) + .expect_get_signature_statuses( + vec![signature(sig_a), signature(sig_b), signature(sig_c)], + SignatureStatusesResult::Consistent(Ok(vec![ + Some(finalized_status()), + None, + Some(finalized_status()), + ])), + ) + .expect_get_transaction(signature(sig_a), GetTransactionResult::Consistent(Ok(None))) + .expect_get_transaction(signature(sig_c), GetTransactionResult::Consistent(Ok(None))); finalize_transactions(runtime).await; @@ -270,10 +270,12 @@ mod finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(current_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![None]))) - .add_stub_response(nonce_read(BOUND_NONCE_SEED)); + .expect_recent_block(CommitmentLevel::Finalized, CURRENT_SLOT, current_block()) + .expect_get_signature_statuses( + vec![sweep], + SignatureStatusesResult::Consistent(Ok(vec![None])), + ) + .expect_get_account_info(NONCE_ACCOUNT, nonce_read(BOUND_NONCE_SEED)); finalize_transactions(runtime).await; @@ -289,17 +291,19 @@ mod finalization { async fn should_not_expire_transaction_if_status_check_fails() { setup(); - submit_sweep_transaction(EXPIRED_BLOCK_HEIGHT); + let signature = submit_sweep_transaction(EXPIRED_BLOCK_HEIGHT); let events_before = EventsAssert::from_recorded(); let finalize_runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(current_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Err( - RpcError::ValidationError("Error".to_string()), - ))); + .expect_recent_block(CommitmentLevel::Finalized, CURRENT_SLOT, current_block()) + .expect_get_signature_statuses( + vec![signature], + SignatureStatusesResult::Consistent(Err(RpcError::ValidationError( + "Error".to_string(), + ))), + ); finalize_transactions(finalize_runtime).await; @@ -314,33 +318,16 @@ mod finalization { #[tokio::test] async fn should_fetch_the_current_block_height_at_finalized_commitment() { setup(); - submit_sweep_transaction(OLDEST_VALID_BLOCK_HEIGHT); + let signature = submit_sweep_transaction(OLDEST_VALID_BLOCK_HEIGHT); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(current_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![None]))); + .expect_recent_block(CommitmentLevel::Finalized, CURRENT_SLOT, current_block()) + .expect_get_signature_statuses( + vec![signature], + SignatureStatusesResult::Consistent(Ok(vec![None])), + ); - finalize_transactions(runtime.clone()).await; - - let calls = runtime.sent_update_calls(); - let get_slot = calls.iter().find(|call| call.method == "getSlot").unwrap(); - let (_sources, _config, slot_params): ( - RpcSources, - Option, - Option, - ) = get_slot.args(); - assert_eq!( - slot_params.and_then(|params| params.commitment), - Some(CommitmentLevel::Finalized) - ); - let get_block = calls.iter().find(|call| call.method == "getBlock").unwrap(); - let (_sources, _config, block_params): (RpcSources, Option, GetBlockParams) = - get_block.args(); - assert_eq!( - block_params.commitment, - Some(GetBlockCommitmentLevel::Finalized) - ); + finalize_transactions(runtime).await; } struct ExpiryCase { @@ -376,9 +363,11 @@ mod finalization { let signature = submit_sweep_transaction(case.transaction_block_height); let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(current_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![None]))); + .expect_recent_block(CommitmentLevel::Finalized, CURRENT_SLOT, current_block()) + .expect_get_signature_statuses( + vec![signature], + SignatureStatusesResult::Consistent(Ok(vec![None])), + ); finalize_transactions(runtime).await; @@ -425,10 +414,10 @@ mod withdrawal_finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(nonce_read(UNSEEN_NONCE_SEED)) - .add_stub_response(succeeded_withdrawal_response(&signature)); + .expect_get_account_info(NONCE_ACCOUNT, nonce_read(UNSEEN_NONCE_SEED)) + .expect_get_transaction(signature, succeeded_withdrawal_response(&signature)); - finalize_transactions(runtime.clone()).await; + finalize_transactions(runtime).await; EventsAssert::from_recorded() .expect_contains_event_eq(EventType::SucceededTransaction { signature }); @@ -437,10 +426,6 @@ mod withdrawal_finalization { assert!(s.succeeded_transactions().contains(&signature)); }); assert_nonce_account_is_free(); - assert_eq!( - called_methods(&runtime), - vec!["getAccountInfo", "getTransaction"] - ); } #[tokio::test] @@ -450,8 +435,8 @@ mod withdrawal_finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(nonce_read(UNSEEN_NONCE_SEED)) - .add_stub_response(failed_withdrawal_response(&signature)); + .expect_get_account_info(NONCE_ACCOUNT, nonce_read(UNSEEN_NONCE_SEED)) + .expect_get_transaction(signature, failed_withdrawal_response(&signature)); finalize_transactions(runtime).await; @@ -472,18 +457,14 @@ mod withdrawal_finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time_from(min_rebroadcast_age_nanos()) - .add_stub_response(nonce_read(BOUND_NONCE_SEED)) - .add_stub_response(SendTransactionResult::Consistent(Ok(signature.into()))); + .expect_get_account_info(NONCE_ACCOUNT, nonce_read(BOUND_NONCE_SEED)) + .expect_send_exact_transaction_skipping_preflight( + submitted_transaction(&signature), + SendTransactionResult::Consistent(Ok(signature.into())), + ); - finalize_transactions(runtime.clone()).await; + finalize_transactions(runtime).await; - let sent = runtime.sent_transactions(); - assert_eq!(sent.len(), 1); - assert_eq!( - sent[0].get_transaction(), - encoded_submitted_transaction(&signature) - ); - assert_eq!(sent[0].skip_preflight, Some(true)); assert_eq!(EventsAssert::from_recorded(), events_before); read_state(|s| assert!(s.submitted_transactions().contains_key(&signature))); } @@ -495,20 +476,20 @@ mod withdrawal_finalization { let too_early = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(nonce_read(BOUND_NONCE_SEED)); - - finalize_transactions(too_early.clone()).await; + .expect_get_account_info(NONCE_ACCOUNT, nonce_read(BOUND_NONCE_SEED)); - assert!(too_early.sent_transactions().is_empty()); + finalize_transactions(too_early).await; let old_enough = TestCanisterRuntime::new() .with_increasing_time_from(min_rebroadcast_age_nanos()) - .add_stub_response(nonce_read(BOUND_NONCE_SEED)) - .add_stub_response(SendTransactionResult::Consistent(Ok(signature.into()))); + .expect_get_account_info(NONCE_ACCOUNT, nonce_read(BOUND_NONCE_SEED)) + .expect_send_transaction_skipping_preflight( + signature, + SendTransactionResult::Consistent(Ok(signature.into())), + ); - finalize_transactions(old_enough.clone()).await; + finalize_transactions(old_enough).await; - assert_eq!(old_enough.sent_transactions().len(), 1); read_state(|s| assert!(s.submitted_transactions().contains_key(&signature))); } @@ -522,12 +503,11 @@ mod withdrawal_finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time_from(min_rebroadcast_age_nanos()) - .add_stub_response(nonce_read(STALE_NONCE_SEED)); + .expect_get_account_info(NONCE_ACCOUNT, nonce_read(STALE_NONCE_SEED)); - finalize_transactions(runtime.clone()).await; + finalize_transactions(runtime).await; assert_eq!(EventsAssert::from_recorded(), events_before); - assert!(runtime.sent_transactions().is_empty()); read_state(|s| assert!(s.submitted_transactions().contains_key(&signature))); assert_eq!( undecided_withdrawal_transaction_count(UndecidedWithdrawalReason::StaleNonce), @@ -543,13 +523,12 @@ mod withdrawal_finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time_from(min_rebroadcast_age_nanos()) - .add_stub_response(nonce_read(UNSEEN_NONCE_SEED)) - .add_stub_response(GetTransactionResult::Consistent(Ok(None))); + .expect_get_account_info(NONCE_ACCOUNT, nonce_read(UNSEEN_NONCE_SEED)) + .expect_get_transaction(signature, GetTransactionResult::Consistent(Ok(None))); - finalize_transactions(runtime.clone()).await; + finalize_transactions(runtime).await; assert_eq!(EventsAssert::from_recorded(), events_before); - assert!(runtime.sent_transactions().is_empty()); read_state(|s| { assert!(s.submitted_transactions().contains_key(&signature)); assert_eq!(s.nonce_pool().num_free_accounts(), 0); @@ -577,8 +556,11 @@ mod withdrawal_finalization { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(nonce_read(UNSEEN_NONCE_SEED)) - .add_stub_response(succeeded_transaction_response(&signature, other_message)); + .expect_get_account_info(NONCE_ACCOUNT, nonce_read(UNSEEN_NONCE_SEED)) + .expect_get_transaction( + signature, + succeeded_transaction_response(&signature, other_message), + ); finalize_transactions(runtime).await; @@ -608,7 +590,7 @@ fn min_rebroadcast_age_nanos() -> u64 { MIN_REBROADCAST_AGE.as_nanos() as u64 } -fn encoded_submitted_transaction(signature: &solana_signature::Signature) -> String { +fn submitted_transaction(signature: &solana_signature::Signature) -> Transaction { let message = read_state(|s| { match s .submitted_transactions() @@ -622,14 +604,10 @@ fn encoded_submitted_transaction(signature: &solana_signature::Signature) -> Str other => panic!("expected a withdrawal transaction, got {other:?}"), } }); - let transaction = Transaction { + Transaction { signatures: vec![*signature], message, - }; - SendTransactionParams::try_from(transaction) - .expect("the transaction is serializable") - .get_transaction() - .to_string() + } } /// Submits a withdrawal transaction under `signature(i)` bound to [`NONCE_ACCOUNT`] @@ -658,11 +636,15 @@ fn nonce_read(nonce_seed: usize) -> GetAccountInfoResult { GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(minter_address, nonce_seed)))) } -fn called_methods(runtime: &TestCanisterRuntime) -> Vec { - runtime - .sent_update_calls() - .into_iter() - .map(|call| call.method) +/// The signatures of `num_transactions` submitted transactions, batched as +/// `finalize_transactions` checks their statuses: ordered as the state stores them, in +/// chunks of [`MAX_SIGNATURES_PER_STATUS_CHECK`]. +fn status_check_batches(num_transactions: usize) -> Vec> { + let mut signatures: Vec<_> = (0..num_transactions).map(signature).collect(); + signatures.sort_unstable(); + signatures + .chunks(MAX_SIGNATURES_PER_STATUS_CHECK) + .map(<[solana_signature::Signature]>::to_vec) .collect() } diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 3db8fb614..c73e73955 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -20,9 +20,7 @@ use crate::{ use assert_matches::assert_matches; use ic_canister_runtime::IcError; use sol_rpc_types::{ - CommitmentLevel, GetBlockCommitmentLevel, GetBlockParams, GetSignatureStatusesParams, - GetSlotParams, GetSlotRpcConfig, GetTransactionParams, HttpOutcallError, RpcConfig, RpcError, - RpcSource, RpcSources, SendTransactionParams, SupportedRpcProviderId, + CommitmentLevel, HttpOutcallError, RpcError, RpcSource, SupportedRpcProviderId, }; use solana_transaction::{Message, Transaction}; use solana_transaction_status_client_types::{EncodedTransaction, TransactionBinaryEncoding}; @@ -36,8 +34,8 @@ mod get_balance_tests { #[tokio::test] async fn should_return_balance() { init_state(); - let runtime = - TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok(42))); + let runtime = TestCanisterRuntime::new() + .expect_get_balance(DEPOSIT_ADDRESS, MultiRpcResult::Consistent(Ok(42))); let result = get_balance(&runtime, DEPOSIT_ADDRESS).await; @@ -55,17 +53,22 @@ mod get_balance_tests { for (runtime, expected) in [ ( - TestCanisterRuntime::new().add_stub_error(IcError::CallPerformFailed), + TestCanisterRuntime::new() + .expect_get_balance(DEPOSIT_ADDRESS, IcError::CallPerformFailed), GetBalanceError::IcError(IcError::CallPerformFailed), ), ( - TestCanisterRuntime::new() - .add_stub_response(MultiRpcResult::Consistent(Err(rpc_error.clone()))), + TestCanisterRuntime::new().expect_get_balance( + DEPOSIT_ADDRESS, + MultiRpcResult::Consistent(Err(rpc_error.clone())), + ), GetBalanceError::RpcError(rpc_error.clone()), ), ( - TestCanisterRuntime::new() - .add_stub_response(MultiRpcResult::Inconsistent(inconsistent.clone())), + TestCanisterRuntime::new().expect_get_balance( + DEPOSIT_ADDRESS, + MultiRpcResult::Inconsistent(inconsistent.clone()), + ), GetBalanceError::InconsistentRpcResults, ), ] { @@ -88,7 +91,10 @@ mod get_transaction_tests { async fn should_fail_if_get_transaction_fails() { init_state(); - let runtime = TestCanisterRuntime::new().add_stub_error(IcError::CallPerformFailed); + let runtime = TestCanisterRuntime::new().expect_get_transaction( + legacy_deposit_transaction_signature(), + IcError::CallPerformFailed, + ); let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; @@ -108,8 +114,10 @@ mod get_transaction_tests { parsing_error: None, }); - let runtime = TestCanisterRuntime::new() - .add_stub_response(MultiRpcResult::Consistent(Err(rpc_error.clone()))); + let runtime = TestCanisterRuntime::new().expect_get_transaction( + legacy_deposit_transaction_signature(), + MultiRpcResult::Consistent(Err(rpc_error.clone())), + ); let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; @@ -131,8 +139,10 @@ mod get_transaction_tests { ), ]; - let runtime = - TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Inconsistent(results)); + let runtime = TestCanisterRuntime::new().expect_get_transaction( + legacy_deposit_transaction_signature(), + MultiRpcResult::Inconsistent(results), + ); let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; @@ -143,8 +153,10 @@ mod get_transaction_tests { async fn should_return_empty_if_transaction_not_found() { init_state(); - let runtime = - TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok(None))); + let runtime = TestCanisterRuntime::new().expect_get_transaction( + legacy_deposit_transaction_signature(), + MultiRpcResult::Consistent(Ok(None)), + ); let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; @@ -155,12 +167,13 @@ mod get_transaction_tests { async fn should_fail_if_returned_transaction_has_another_signature() { init_state(); - let runtime = TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok( - Some(legacy_deposit_transaction().try_into().unwrap()), - ))); - let queried = solana_signature::Signature::from([7; 64]); + let runtime = TestCanisterRuntime::new().expect_get_transaction( + queried, + MultiRpcResult::Consistent(Ok(Some(legacy_deposit_transaction().try_into().unwrap()))), + ); + let result = get_transaction(&runtime, queried).await; assert_eq!( @@ -182,9 +195,10 @@ mod get_transaction_tests { TransactionBinaryEncoding::Base64, ); - let runtime = TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok( - Some(transaction.try_into().unwrap()), - ))); + let runtime = TestCanisterRuntime::new().expect_get_transaction( + legacy_deposit_transaction_signature(), + MultiRpcResult::Consistent(Ok(Some(transaction.try_into().unwrap()))), + ); let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; @@ -218,9 +232,10 @@ mod get_transaction_tests { TransactionBinaryEncoding::Base64, ); - let runtime = TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok( - Some(transaction.try_into().unwrap()), - ))); + let runtime = TestCanisterRuntime::new().expect_get_transaction( + legacy_deposit_transaction_signature(), + MultiRpcResult::Consistent(Ok(Some(transaction.try_into().unwrap()))), + ); let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; @@ -236,9 +251,10 @@ mod get_transaction_tests { async fn should_return_transaction() { init_state(); - let runtime = TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok( - Some(legacy_deposit_transaction().try_into().unwrap()), - ))); + let runtime = TestCanisterRuntime::new().expect_get_transaction( + legacy_deposit_transaction_signature(), + MultiRpcResult::Consistent(Ok(Some(legacy_deposit_transaction().try_into().unwrap()))), + ); let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; @@ -248,20 +264,14 @@ mod get_transaction_tests { #[tokio::test] async fn should_leave_the_response_size_estimate_to_the_sol_rpc_canister() { init_state(); - let runtime = - TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok(None))); + let runtime = TestCanisterRuntime::new().expect_get_transaction( + legacy_deposit_transaction_signature(), + MultiRpcResult::Consistent(Ok(None)), + ); let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; assert_eq!(result, Ok(None)); - let [call] = runtime.sent_update_calls().try_into().unwrap(); - assert_eq!(call.method, "getTransaction"); - let (_sources, config, _params): (RpcSources, Option, GetTransactionParams) = - call.args(); - assert_eq!( - config.and_then(|config| config.response_size_estimate), - None - ); } } @@ -274,24 +284,15 @@ mod get_signature_statuses_tests { #[tokio::test] async fn should_leave_the_response_size_estimate_to_the_sol_rpc_canister() { init_state(); - let runtime = TestCanisterRuntime::new() - .add_stub_response(MultiRpcResult::Consistent(Ok(vec![None]))); + let runtime = TestCanisterRuntime::new().expect_get_signature_statuses( + vec![legacy_deposit_transaction_signature()], + MultiRpcResult::Consistent(Ok(vec![None])), + ); let result = get_signature_statuses(&runtime, &[legacy_deposit_transaction_signature()]).await; assert_eq!(result, Ok(vec![None])); - let [call] = runtime.sent_update_calls().try_into().unwrap(); - assert_eq!(call.method, "getSignatureStatuses"); - let (_sources, config, _params): ( - RpcSources, - Option, - GetSignatureStatusesParams, - ) = call.args(); - assert_eq!( - config.and_then(|config| config.response_size_estimate), - None - ); } } @@ -305,7 +306,9 @@ mod submit_transaction_tests { init_state(); let expected_signature = signature(); - let runtime = TestCanisterRuntime::new().add_stub_response( + let runtime = TestCanisterRuntime::new().expect_send_transaction( + transaction_signature(), + CommitmentLevel::Confirmed, SendTransactionResult::Consistent(Ok(expected_signature.clone())), ); @@ -318,7 +321,11 @@ mod submit_transaction_tests { async fn should_fail_on_ic_error() { init_state(); - let runtime = TestCanisterRuntime::new().add_stub_error(IcError::CallPerformFailed); + let runtime = TestCanisterRuntime::new().expect_send_transaction( + transaction_signature(), + CommitmentLevel::Confirmed, + IcError::CallPerformFailed, + ); let result = submit_transaction(&runtime, transaction(), CommitmentLevel::Confirmed).await; @@ -338,8 +345,11 @@ mod submit_transaction_tests { parsing_error: None, }); - let runtime = TestCanisterRuntime::new() - .add_stub_response(SendTransactionResult::Consistent(Err(rpc_error.clone()))); + let runtime = TestCanisterRuntime::new().expect_send_transaction( + transaction_signature(), + CommitmentLevel::Confirmed, + SendTransactionResult::Consistent(Err(rpc_error.clone())), + ); let result = submit_transaction(&runtime, transaction(), CommitmentLevel::Confirmed).await; @@ -361,8 +371,11 @@ mod submit_transaction_tests { ), ]; - let runtime = TestCanisterRuntime::new() - .add_stub_response(SendTransactionResult::Inconsistent(results)); + let runtime = TestCanisterRuntime::new().expect_send_transaction( + transaction_signature(), + CommitmentLevel::Confirmed, + SendTransactionResult::Inconsistent(results), + ); let result = submit_transaction(&runtime, transaction(), CommitmentLevel::Confirmed).await; @@ -372,59 +385,60 @@ mod submit_transaction_tests { #[tokio::test] async fn should_keep_the_preflight_simulation() { init_state(); - let runtime = TestCanisterRuntime::new() - .add_stub_response(SendTransactionResult::Consistent(Ok(signature()))); + let runtime = TestCanisterRuntime::new().expect_send_transaction( + transaction_signature(), + CommitmentLevel::Confirmed, + SendTransactionResult::Consistent(Ok(signature())), + ); let result = submit_transaction(&runtime, transaction(), CommitmentLevel::Confirmed).await; assert_eq!(result, Ok(signature().into())); - assert_eq!(sent_params(&runtime).skip_preflight, None); } #[tokio::test] async fn should_simulate_at_the_requested_commitment() { init_state(); for commitment in [CommitmentLevel::Confirmed, CommitmentLevel::Finalized] { - let runtime = TestCanisterRuntime::new() - .add_stub_response(SendTransactionResult::Consistent(Ok(signature()))); + let runtime = TestCanisterRuntime::new().expect_send_transaction( + transaction_signature(), + commitment.clone(), + SendTransactionResult::Consistent(Ok(signature())), + ); - let result = submit_transaction(&runtime, transaction(), commitment.clone()).await; + let result = submit_transaction(&runtime, transaction(), commitment).await; assert_eq!(result, Ok(signature().into())); - assert_eq!(sent_params(&runtime).preflight_commitment, Some(commitment)); } } #[tokio::test] async fn should_skip_the_preflight_simulation_when_requested() { init_state(); - let runtime = TestCanisterRuntime::new() - .add_stub_response(SendTransactionResult::Consistent(Ok(signature()))); + let runtime = TestCanisterRuntime::new().expect_send_transaction_skipping_preflight( + transaction_signature(), + SendTransactionResult::Consistent(Ok(signature())), + ); let result = submit_transaction_skipping_preflight(&runtime, transaction()).await; assert_eq!(result, Ok(signature().into())); - assert_eq!(sent_params(&runtime).skip_preflight, Some(true)); - } - - fn sent_params(runtime: &TestCanisterRuntime) -> SendTransactionParams { - let [call] = runtime.sent_update_calls().try_into().unwrap(); - assert_eq!(call.method, "sendTransaction"); - let (_sources, _config, params): (RpcSources, Option, SendTransactionParams) = - call.args(); - params } fn transaction() -> Transaction { let message = Message::new(&[], None); Transaction { - signatures: vec![signature().into()], + signatures: vec![transaction_signature()], message, } } + fn transaction_signature() -> solana_signature::Signature { + solana_signature::Signature::from([0x42; 64]) + } + fn signature() -> sol_rpc_types::Signature { - solana_signature::Signature::from([0x42; 64]).into() + transaction_signature().into() } } @@ -438,7 +452,8 @@ mod get_nonce_account_tests { async fn should_return_the_authority_and_nonce_value() { init_state(); - let runtime = TestCanisterRuntime::new().add_stub_response( + let runtime = TestCanisterRuntime::new().expect_get_account_info( + nonce_account_address(), GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), ); @@ -457,8 +472,10 @@ mod get_nonce_account_tests { async fn should_fail_if_account_not_found() { init_state(); - let runtime = TestCanisterRuntime::new() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(None))); + let runtime = TestCanisterRuntime::new().expect_get_account_info( + nonce_account_address(), + GetAccountInfoResult::Consistent(Ok(None)), + ); let result = get_nonce_account(&runtime, nonce_account_address()).await; @@ -476,17 +493,22 @@ mod get_nonce_account_tests { for (runtime, expected) in [ ( - TestCanisterRuntime::new().add_stub_error(IcError::CallPerformFailed), + TestCanisterRuntime::new() + .expect_get_account_info(nonce_account_address(), IcError::CallPerformFailed), GetNonceAccountError::IcError(IcError::CallPerformFailed), ), ( - TestCanisterRuntime::new() - .add_stub_response(GetAccountInfoResult::Consistent(Err(rpc_error.clone()))), + TestCanisterRuntime::new().expect_get_account_info( + nonce_account_address(), + GetAccountInfoResult::Consistent(Err(rpc_error.clone())), + ), GetNonceAccountError::RpcError(rpc_error.clone()), ), ( - TestCanisterRuntime::new() - .add_stub_response(GetAccountInfoResult::Inconsistent(inconsistent.clone())), + TestCanisterRuntime::new().expect_get_account_info( + nonce_account_address(), + GetAccountInfoResult::Inconsistent(inconsistent.clone()), + ), GetNonceAccountError::InconsistentRpcResults, ), ] { @@ -510,8 +532,10 @@ mod get_nonce_account_tests { }; for account in [foreign_owner_account, executable_account] { - let runtime = TestCanisterRuntime::new() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some(account)))); + let runtime = TestCanisterRuntime::new().expect_get_account_info( + nonce_account_address(), + GetAccountInfoResult::Consistent(Ok(Some(account))), + ); let result = get_nonce_account(&runtime, nonce_account_address()).await; @@ -526,7 +550,8 @@ mod get_nonce_account_tests { async fn should_fail_if_account_is_a_legacy_nonce_account() { init_state(); - let runtime = TestCanisterRuntime::new().add_stub_response( + let runtime = TestCanisterRuntime::new().expect_get_account_info( + nonce_account_address(), GetAccountInfoResult::Consistent(Ok(Some(legacy_nonce_account_info(MINTER_ADDRESS)))), ); @@ -551,8 +576,10 @@ mod get_nonce_account_tests { invalid_base64_account, invalid_nonce_state_account, ] { - let runtime = TestCanisterRuntime::new() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some(account)))); + let runtime = TestCanisterRuntime::new().expect_get_account_info( + nonce_account_address(), + GetAccountInfoResult::Consistent(Ok(Some(account))), + ); let result = get_nonce_account(&runtime, nonce_account_address()).await; @@ -567,20 +594,17 @@ mod get_nonce_account_tests { mod get_recent_block_tests { use super::*; - type GetSlotResult = sol_rpc_types::MultiRpcResult; - type GetBlockResult = sol_rpc_types::MultiRpcResult>; - const SLOT: sol_rpc_types::Slot = 978458723; #[tokio::test] async fn should_return_slot_blockhash_and_block_height_on_success() { init_state(); let block_height = BlockHeight::new(SLOT - 10); - let runtime = TestCanisterRuntime::new() - .add_stub_response(GetSlotResult::Consistent(Ok(SLOT))) - .add_stub_response(GetBlockResult::Consistent(Ok(Some( - confirmed_block_at_height(block_height), - )))); + let runtime = TestCanisterRuntime::new().expect_recent_block( + CommitmentLevel::Finalized, + SLOT, + confirmed_block_at_height(block_height), + ); let result = get_recent_block(&runtime, CommitmentLevel::Finalized).await; @@ -597,14 +621,14 @@ mod get_recent_block_tests { #[tokio::test] async fn should_fail_when_block_has_no_block_height() { init_state(); - let runtime = TestCanisterRuntime::new() - .add_stub_response(GetSlotResult::Consistent(Ok(SLOT))) - .add_stub_response(GetBlockResult::Consistent(Ok(Some( - sol_rpc_types::ConfirmedBlock { - block_height: None, - ..confirmed_block() - }, - )))); + let runtime = TestCanisterRuntime::new().expect_recent_block( + CommitmentLevel::Finalized, + SLOT, + sol_rpc_types::ConfirmedBlock { + block_height: None, + ..confirmed_block() + }, + ); let result = get_recent_block(&runtime, CommitmentLevel::Finalized).await; @@ -617,8 +641,10 @@ mod get_recent_block_tests { #[tokio::test] async fn should_fail_after_retrying() { init_state(); - let runtime = TestCanisterRuntime::new() - .add_recent_block(Err(RpcError::ValidationError("Error".to_string()))); + let runtime = TestCanisterRuntime::new().add_recent_block( + CommitmentLevel::Finalized, + Err(RpcError::ValidationError("Error".to_string())), + ); let result = get_recent_block(&runtime, CommitmentLevel::Finalized).await; @@ -632,36 +658,12 @@ mod get_recent_block_tests { #[tokio::test] async fn should_fetch_slot_and_block_at_the_requested_commitment() { init_state(); - for (commitment, block_commitment) in [ - ( - CommitmentLevel::Confirmed, - GetBlockCommitmentLevel::Confirmed, - ), - ( - CommitmentLevel::Finalized, - GetBlockCommitmentLevel::Finalized, - ), - ] { - let runtime = TestCanisterRuntime::new().add_recent_block(Ok(SLOT)); + for commitment in [CommitmentLevel::Confirmed, CommitmentLevel::Finalized] { + let runtime = TestCanisterRuntime::new().add_recent_block(commitment.clone(), Ok(SLOT)); - let result = get_recent_block(&runtime, commitment.clone()).await; + let result = get_recent_block(&runtime, commitment).await; assert_matches!(result, Ok(Block { slot: SLOT, .. })); - let [get_slot, get_block] = runtime.sent_update_calls().try_into().unwrap(); - assert_eq!(get_slot.method, "getSlot"); - let (_sources, _config, slot_params): ( - RpcSources, - Option, - Option, - ) = get_slot.args(); - assert_eq!( - slot_params.and_then(|params| params.commitment), - Some(commitment) - ); - assert_eq!(get_block.method, "getBlock"); - let (_sources, _config, block_params): (RpcSources, Option, GetBlockParams) = - get_block.args(); - assert_eq!(block_params.commitment, Some(block_commitment)); } } diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 1c3272442..fa0395bef 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -170,7 +170,7 @@ pub fn address(i: usize) -> Address { pub const DEFAULT_BLOCK_HEIGHT: BlockHeight = BlockHeight::new(400_000_000); /// Returns a [`ConfirmedBlock`] with a deterministic blockhash at -/// [`DEFAULT_BLOCK_HEIGHT`], for use in RPC mock stubs. +/// [`DEFAULT_BLOCK_HEIGHT`], for use in expected RPC responses. pub fn confirmed_block() -> sol_rpc_types::ConfirmedBlock { confirmed_block_at_height(DEFAULT_BLOCK_HEIGHT) } diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index 8f4655312..a21ccb2e0 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -1,25 +1,47 @@ use super::{ - confirmed_block, + confirmed_block, ledger_canister_id, signer::{MockSchnorrSigner, SignerExpectation, sign_for}, + sol_rpc_canister_id, stubs::Stubs, }; use crate::{ - constants::GET_RECENT_BLOCK_MAX_TRIES, runtime::CanisterRuntime, signer::SchnorrSigner, + constants::{ + GET_ACCOUNT_INFO_CYCLES, GET_BALANCE_CYCLES, GET_RECENT_BLOCK_MAX_TRIES, + GET_SIGNATURE_STATUSES_CYCLES, GET_TRANSACTION_CYCLES, + }, + runtime::CanisterRuntime, + signer::SchnorrSigner, + test_fixtures::GetTransactionResult, }; use async_trait::async_trait; +use base64::{Engine, engine::general_purpose::STANDARD}; use candid::{ - CandidType, Principal, + CandidType, Decode, Encode, Principal, utils::{ArgumentDecoder, ArgumentEncoder, decode_args, encode_args}, }; -use ic_canister_runtime::{IcError, Runtime, StubRuntime}; +use ic_canister_runtime::{IcError, Runtime}; use ic_cdk::call::{CallPerformFailed, Error as CallError}; use ic_cdk_management_canister::{SchnorrPublicKeyArgs, SchnorrPublicKeyResult}; -use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::{ + icrc1::{ + account::Account, + transfer::{BlockIndex, TransferArg, TransferError}, + }, + icrc2::transfer_from::{TransferFromArgs, TransferFromError}, +}; +use mockall::{mock, predicate::eq}; use serde::de::DeserializeOwned; use sol_rpc_types::{ - MultiRpcResult, RpcConfig, RpcResult, RpcSources, SendTransactionParams, Signature, Slot, + AccountInfo, CommitmentLevel, ConfirmedBlock, ConsensusStrategy, GetAccountInfoEncoding, + GetAccountInfoParams, GetBalanceParams, GetBlockCommitmentLevel, GetBlockParams, + GetSignatureStatusesParams, GetSlotParams, GetSlotRpcConfig, GetTransactionEncoding, + GetTransactionParams, Lamport, MultiRpcResult, RpcConfig, RpcResult, RpcSources, + SendTransactionParams, Slot, SolanaCluster, TransactionDetails, TransactionStatus, }; +use solana_address::Address; +use solana_transaction::Transaction; use std::{ + fmt, future::Future, sync::{Arc, Mutex}, time::Duration, @@ -28,9 +50,18 @@ use tokio::task::yield_now; pub const TEST_CANISTER_ID: Principal = Principal::from_slice(&[0xCA; 10]); +/// The cycles `sol_rpc_client` attaches to a request whose cycles the minter leaves at the +/// default: `getSlot`, `getBlock` without transaction details or rewards, `sendTransaction`. +const SOL_RPC_DEFAULT_REQUEST_CYCLES: u128 = 10_000_000_000; + +/// A [`CanisterRuntime`] whose inter-canister calls are backed by a mockall mock: every call +/// a test expects is registered upfront with one of the `expect_*` builder methods, stating +/// the canister, the method, the arguments, the attached cycles and the response. Each +/// expectation answers exactly one matching call; a call without a matching expectation and +/// an expectation that goes unused both fail the test. #[derive(Clone, Default)] pub struct TestCanisterRuntime { - inter_canister_call_runtime: RecordingStubRuntime, + inter_canister_calls: Arc>, signer: MockSchnorrSigner, times: Stubs, expects_charges: bool, @@ -57,58 +88,190 @@ impl TestCanisterRuntime { TransactionBuilder::new(self, fee_payer, transaction_signature) } - pub fn add_stub_response(mut self, response: Out) -> Self { - self.inter_canister_call_runtime.stub = self - .inter_canister_call_runtime - .stub - .add_stub_response(response); - self + /// Expects one `getSlot` and, if `result` is a slot, one `getBlock` call for it at + /// `commitment`, answered with [`confirmed_block`]. An error is answered to every retried + /// `getSlot` call. + pub fn add_recent_block(self, commitment: CommitmentLevel, result: RpcResult) -> Self { + match result { + Ok(slot) => self.expect_recent_block(commitment, slot, confirmed_block()), + Err(error) => self.expect( + get_slot_call(commitment), + CallResponse::from(MultiRpcResult::::Consistent(Err(error))), + GET_RECENT_BLOCK_MAX_TRIES.get(), + ), + } } - pub fn add_stub_error(mut self, error: IcError) -> Self { - self.inter_canister_call_runtime.stub = - self.inter_canister_call_runtime.stub.add_stub_error(error); - self + /// Expects one `getSlot` call at `commitment` answered with `slot` and one `getBlock` call + /// for `slot` at `commitment` answered with `block`. + pub fn expect_recent_block( + self, + commitment: CommitmentLevel, + slot: Slot, + block: ConfirmedBlock, + ) -> Self { + self.expect_get_slot(commitment.clone(), MultiRpcResult::Consistent(Ok(slot))) + .expect_get_block( + commitment, + slot, + MultiRpcResult::Consistent(Ok(Some(block))), + ) + } + + pub fn expect_get_slot( + self, + commitment: CommitmentLevel, + response: impl Into>>, + ) -> Self { + self.expect_once(get_slot_call(commitment), response.into()) + } + + pub fn expect_get_block( + self, + commitment: CommitmentLevel, + slot: Slot, + response: impl Into>>>, + ) -> Self { + self.expect_once(get_block_call(commitment, slot), response.into()) + } + + pub fn expect_get_balance( + self, + address: Address, + response: impl Into>>, + ) -> Self { + self.expect_once(get_balance_call(address), response.into()) + } + + pub fn expect_get_transaction( + self, + signature: solana_signature::Signature, + response: impl Into>, + ) -> Self { + self.expect_once(get_transaction_call(signature), response.into()) + } + + pub fn expect_get_signature_statuses( + self, + signatures: Vec, + response: impl Into>>>>, + ) -> Self { + self.expect_once(get_signature_statuses_call(&signatures), response.into()) + } + + pub fn expect_get_account_info( + self, + address: Address, + response: impl Into>>>, + ) -> Self { + self.expect_once(get_account_info_call(address), response.into()) + } + + /// Expects one `sendTransaction` call with the providers' preflight simulation at + /// `preflight_commitment` whose transaction carries `transaction_signature` as its first + /// signature, i.e. was signed by the fee payer with it. + pub fn expect_send_transaction( + self, + transaction_signature: solana_signature::Signature, + preflight_commitment: CommitmentLevel, + response: impl Into>>, + ) -> Self { + self.expect_send_transaction_signed_with( + transaction_signature, + Preflight::Simulate(preflight_commitment), + response.into(), + ) + } + + /// Expects one `sendTransaction` call skipping the providers' preflight simulation whose + /// transaction carries `transaction_signature` as its first signature. + pub fn expect_send_transaction_skipping_preflight( + self, + transaction_signature: solana_signature::Signature, + response: impl Into>>, + ) -> Self { + self.expect_send_transaction_signed_with( + transaction_signature, + Preflight::Skip, + response.into(), + ) + } + + /// Expects one `sendTransaction` call skipping the providers' preflight simulation that + /// sends exactly `transaction`. + pub fn expect_send_exact_transaction_skipping_preflight( + self, + transaction: Transaction, + response: impl Into>>, + ) -> Self { + let mut params = SendTransactionParams::try_from(transaction) + .expect("BUG: failed to serialize the expected transaction"); + params.skip_preflight = Some(true); + self.expect_once( + sol_rpc_call( + "sendTransaction", + (rpc_sources(), Some(rpc_config()), params), + SOL_RPC_DEFAULT_REQUEST_CYCLES, + ), + response.into(), + ) } - /// The inter-canister update calls made through this runtime, in call order. - pub fn sent_update_calls(&self) -> Vec { - self.inter_canister_call_runtime - .sent_update_calls + fn expect_send_transaction_signed_with( + self, + transaction_signature: solana_signature::Signature, + preflight: Preflight, + response: CallResponse>, + ) -> Self { + let response = response.encode(); + self.inter_canister_calls .lock() .unwrap() - .clone() - } - - /// The parameters of the `sendTransaction` calls made through this runtime, in call order. - pub fn sent_transactions(&self) -> Vec { - self.sent_update_calls() - .iter() - .filter(|call| call.method == "sendTransaction") - .map(|call| { - let (_sources, _config, params): ( - RpcSources, - Option, - SendTransactionParams, - ) = call.args(); - params + .expect_update_call() + .withf(move |id, method, args, cycles| { + *id == sol_rpc_canister_id() + && method == "sendTransaction" + && *cycles == SOL_RPC_DEFAULT_REQUEST_CYCLES + && sent_transaction(args).is_some_and(|params| { + preflight.matches(¶ms) + && first_signature(¶ms) == Some(transaction_signature) + }) }) - .collect() + .times(1) + .return_once(move |_, _, _, _| response); + self } - pub fn add_recent_block(mut self, result: RpcResult) -> Self { - match result { - Ok(slot) => self - .add_stub_response(MultiRpcResult::Consistent(Ok(slot))) - .add_stub_response(MultiRpcResult::Consistent(Ok(confirmed_block()))), - Err(error) => { - for _ in 0..GET_RECENT_BLOCK_MAX_TRIES.get() { - self = self - .add_stub_response(MultiRpcResult::::Consistent(Err(error.clone()))); - } - self - } - } + pub fn expect_icrc1_transfer( + self, + args: TransferArg, + response: impl Into>>, + ) -> Self { + self.expect_once( + UpdateCall { + canister_id: ledger_canister_id(), + method: "icrc1_transfer", + args: CandidArgs::of((args,)), + cycles: 0, + }, + response.into(), + ) + } + + pub fn expect_icrc2_transfer_from( + self, + args: TransferFromArgs, + response: impl Into>>, + ) -> Self { + self.expect_once( + UpdateCall { + canister_id: ledger_canister_id(), + method: "icrc2_transfer_from", + args: CandidArgs::of((args,)), + cycles: 0, + }, + response.into(), + ) } pub fn add_times(mut self, times: I) -> Self @@ -177,12 +340,37 @@ impl TestCanisterRuntime { pub(crate) fn schnorr_public_key_call_count(&self) -> usize { *self.schnorr_public_key_call_count.lock().unwrap() } + + fn expect_once(self, call: UpdateCall, response: CallResponse) -> Self { + self.expect(call, response, 1) + } + + fn expect( + self, + call: UpdateCall, + response: CallResponse, + times: usize, + ) -> Self { + let response = response.encode(); + self.inter_canister_calls + .lock() + .unwrap() + .expect_update_call() + .with( + eq(call.canister_id), + eq(call.method.to_string()), + eq(call.args), + eq(call.cycles), + ) + .times(times) + .returning(move |_, _, _, _| response.clone()); + self + } } impl CanisterRuntime for TestCanisterRuntime { fn inter_canister_call_runtime(&self) -> impl Runtime { - // This clone returns a new reference to the same stubs - self.inter_canister_call_runtime.clone() + MockRuntime(self.inter_canister_calls.clone()) } fn signer(&self) -> impl SchnorrSigner { @@ -240,36 +428,75 @@ impl CanisterRuntime for TestCanisterRuntime { } } -/// Wraps [`StubRuntime`] to record the method and Candid-encoded arguments of every -/// update call, so that a test can assert exactly what was sent. -#[derive(Clone, Default)] -struct RecordingStubRuntime { - stub: StubRuntime, - sent_update_calls: Arc>>, +/// Suspends the caller once, so that concurrent callers all reach the call before any of +/// them sees its response, as they do on the IC. +async fn suspend_like_an_inter_canister_call() { + yield_now().await; +} + +/// How the mock answers one expected inter-canister call: with a Candid reply or by failing +/// the call itself. Both the reply type and [`IcError`] convert into it, so an expectation +/// takes either directly. +pub enum CallResponse { + Reply(Out), + Failed(IcError), +} + +impl CallResponse { + fn encode(self) -> Result, IcError> { + match self { + Self::Reply(reply) => { + Ok(Encode!(&reply).expect("BUG: failed to encode Candid response")) + } + Self::Failed(error) => Err(error), + } + } } -/// An inter-canister update call recorded by [`TestCanisterRuntime`]. -#[derive(Clone, Debug, PartialEq)] -pub struct SentUpdateCall { - pub method: String, - args: Vec, +impl From> for CallResponse> { + fn from(result: MultiRpcResult) -> Self { + Self::Reply(result) + } } -impl SentUpdateCall { - /// Decodes the single Candid argument of the recorded call. - pub fn single_arg(&self) -> Arg { - let (arg,) = self.args(); - arg +impl From> for CallResponse> { + fn from(result: Result) -> Self { + Self::Reply(result) } +} + +impl From for CallResponse { + fn from(error: IcError) -> Self { + Self::Failed(error) + } +} - /// Decodes the Candid arguments of the recorded call. - pub fn args ArgumentDecoder<'a>>(&self) -> Args { - decode_args(&self.args).expect("Failed to decode the call arguments") +mock! { + InterCanisterCalls { + fn update_call( + &self, + id: Principal, + method: String, + args: CandidArgs, + cycles: u128, + ) -> Result, IcError>; + + fn query_call( + &self, + id: Principal, + method: String, + args: CandidArgs, + ) -> Result, IcError>; } } +/// The [`Runtime`] handed to the code under test: it erases each call's generic arguments +/// into their Candid encoding, relays the call to the shared [`MockInterCanisterCalls`], and +/// decodes the expectation's response. +struct MockRuntime(Arc>); + #[async_trait] -impl Runtime for RecordingStubRuntime { +impl Runtime for MockRuntime { async fn update_call( &self, id: Principal, @@ -281,11 +508,13 @@ impl Runtime for RecordingStubRuntime { In: ArgumentEncoder + Send, Out: CandidType + DeserializeOwned, { - self.sent_update_calls.lock().unwrap().push(SentUpdateCall { - method: method.to_string(), - args: encode_args(args).expect("Failed to encode the call arguments"), - }); - self.stub.update_call(id, method, (), cycles).await + let response = self.0.lock().unwrap().update_call( + id, + method.to_string(), + CandidArgs::of(args), + cycles, + )?; + Ok(Decode!(&response, Out).expect("BUG: failed to decode Candid response")) } async fn query_call( @@ -298,20 +527,220 @@ impl Runtime for RecordingStubRuntime { In: ArgumentEncoder + Send, Out: CandidType + DeserializeOwned, { - self.stub.query_call(id, method, args).await + let response = + self.0 + .lock() + .unwrap() + .query_call(id, method.to_string(), CandidArgs::of(args)); + Ok(Decode!(&response?, Out).expect("BUG: failed to decode Candid response")) } } -/// Suspends the caller once, so that concurrent callers all reach the call before any of -/// them sees its response, as they do on the IC. -async fn suspend_like_an_inter_canister_call() { - yield_now().await; +/// The Candid-encoded arguments of an inter-canister call, compared by encoding and printed +/// as Candid text in mockall's failure messages. +#[derive(Clone, PartialEq, Eq)] +pub struct CandidArgs(Vec); + +impl CandidArgs { + fn of(args: In) -> Self { + Self(encode_args(args).expect("BUG: failed to encode Candid arguments")) + } + + fn decode(&self) -> Result + where + Args: for<'a> ArgumentDecoder<'a>, + { + decode_args(&self.0) + } +} + +impl fmt::Debug for CandidArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match candid_parser::IDLArgs::from_bytes(&self.0) { + Ok(args) => write!(f, "{args}"), + Err(_) => write!(f, "{:?}", self.0), + } + } +} + +struct UpdateCall { + canister_id: Principal, + method: &'static str, + args: CandidArgs, + cycles: u128, +} + +fn sol_rpc_call(method: &'static str, args: In, cycles: u128) -> UpdateCall { + UpdateCall { + canister_id: sol_rpc_canister_id(), + method, + args: CandidArgs::of(args), + cycles, + } +} + +/// The [`RpcSources`] the minter's SOL RPC client sends with every call under +/// [`super::valid_init_args`]. +fn rpc_sources() -> RpcSources { + RpcSources::Default(SolanaCluster::Mainnet) +} + +/// The [`RpcConfig`] the minter's SOL RPC client sends with every call. +fn rpc_config() -> RpcConfig { + RpcConfig { + response_consensus: Some(ConsensusStrategy::Threshold { + min: 3, + total: Some(4), + }), + ..RpcConfig::default() + } +} + +/// How a `sendTransaction` call is expected to treat the providers' preflight simulation. +enum Preflight { + Simulate(CommitmentLevel), + Skip, +} + +impl Preflight { + fn matches(&self, params: &SendTransactionParams) -> bool { + match self { + Preflight::Simulate(commitment) => { + params.skip_preflight.is_none() + && params.preflight_commitment.as_ref() == Some(commitment) + } + Preflight::Skip => { + params.skip_preflight == Some(true) && params.preflight_commitment.is_none() + } + } + } +} + +fn get_slot_call(commitment: CommitmentLevel) -> UpdateCall { + sol_rpc_call( + "getSlot", + ( + rpc_sources(), + Some(GetSlotRpcConfig::from(rpc_config())), + Some(GetSlotParams { + commitment: Some(commitment), + ..GetSlotParams::default() + }), + ), + SOL_RPC_DEFAULT_REQUEST_CYCLES, + ) +} + +fn get_block_call(commitment: CommitmentLevel, slot: Slot) -> UpdateCall { + sol_rpc_call( + "getBlock", + ( + rpc_sources(), + Some(rpc_config()), + GetBlockParams { + slot, + commitment: Some(block_commitment(commitment)), + max_supported_transaction_version: Some(0), + transaction_details: Some(TransactionDetails::None), + rewards: Some(false), + }, + ), + SOL_RPC_DEFAULT_REQUEST_CYCLES, + ) +} + +/// The `getBlock` commitment the SOL RPC client derives from its default commitment level. +fn block_commitment(commitment: CommitmentLevel) -> GetBlockCommitmentLevel { + match commitment { + CommitmentLevel::Processed | CommitmentLevel::Confirmed => { + GetBlockCommitmentLevel::Confirmed + } + CommitmentLevel::Finalized => GetBlockCommitmentLevel::Finalized, + } +} + +fn get_balance_call(address: Address) -> UpdateCall { + sol_rpc_call( + "getBalance", + ( + rpc_sources(), + Some(rpc_config()), + GetBalanceParams { + commitment: Some(CommitmentLevel::Finalized), + ..address.into() + }, + ), + GET_BALANCE_CYCLES, + ) +} + +fn get_transaction_call(signature: solana_signature::Signature) -> UpdateCall { + sol_rpc_call( + "getTransaction", + ( + rpc_sources(), + Some(rpc_config()), + GetTransactionParams { + commitment: Some(CommitmentLevel::Finalized), + max_supported_transaction_version: Some(0), + encoding: Some(GetTransactionEncoding::Base64), + ..signature.into() + }, + ), + GET_TRANSACTION_CYCLES, + ) +} + +fn get_signature_statuses_call(signatures: &[solana_signature::Signature]) -> UpdateCall { + let params = GetSignatureStatusesParams { + search_transaction_history: Some(true), + ..GetSignatureStatusesParams::try_from(signatures.iter().collect::>()) + .expect("BUG: too many signatures for one getSignatureStatuses call") + }; + sol_rpc_call( + "getSignatureStatuses", + (rpc_sources(), Some(rpc_config()), params), + GET_SIGNATURE_STATUSES_CYCLES, + ) +} + +fn get_account_info_call(address: Address) -> UpdateCall { + sol_rpc_call( + "getAccountInfo", + ( + rpc_sources(), + Some(rpc_config()), + GetAccountInfoParams { + commitment: Some(CommitmentLevel::Finalized), + encoding: Some(GetAccountInfoEncoding::Base64), + ..GetAccountInfoParams::from_pubkey(address) + }, + ), + GET_ACCOUNT_INFO_CYCLES, + ) +} + +/// The parameters of the given `sendTransaction` arguments, if they are sent with the +/// minter's RPC sources and configuration. +fn sent_transaction(args: &CandidArgs) -> Option { + let (sources, config, params): (RpcSources, Option, SendTransactionParams) = + args.decode().ok()?; + (sources == rpc_sources() && config == Some(rpc_config())).then_some(params) +} + +/// The first signature of the sent transaction, i.e. the fee payer's signature identifying +/// the submitted transaction. +fn first_signature(params: &SendTransactionParams) -> Option { + let transaction = STANDARD.decode(params.get_transaction()).ok()?; + let transaction: Transaction = bincode::deserialize(&transaction).ok()?; + transaction.signatures.first().copied() } /// Expects the fee payer to sign with the transaction signature, answers the -/// `sendTransaction` call with it, and expects every account added with -/// [`Self::add_signers`] to sign with its own derived signature, which the test reads back -/// with `account_signature`. +/// `sendTransaction` call for the transaction carrying it, simulated at the `confirmed` +/// commitment of a sweep, with that same signature, and +/// expects every account added with [`Self::add_signers`] to sign with its own derived +/// signature, which the test reads back with `account_signature`. /// /// Chain a further [`TestCanisterRuntime::transaction_builder`] onto [`Self::build`] for /// each additional transaction a test expects. @@ -326,9 +755,11 @@ impl TransactionBuilder { Self( runtime .add_signer(sign_for(&fee_payer).expect([Ok(transaction_signature)])) - .add_stub_response(MultiRpcResult::::Consistent(Ok( - transaction_signature.into(), - ))), + .expect_send_transaction( + transaction_signature, + CommitmentLevel::Confirmed, + MultiRpcResult::Consistent(Ok(transaction_signature.into())), + ), ) } diff --git a/minter/src/test_fixtures/tests.rs b/minter/src/test_fixtures/tests.rs index cb05b2b83..518bda6b3 100644 --- a/minter/src/test_fixtures/tests.rs +++ b/minter/src/test_fixtures/tests.rs @@ -236,3 +236,91 @@ async fn should_panic_when_a_given_sequence_is_not_exhausted() { assert_eq!(sign(&signer, &account(1)).await, signature(0xAA)); } + +mod inter_canister_calls { + use crate::{ + runtime::CanisterRuntime, + test_fixtures::{ledger_canister_id, runtime::TestCanisterRuntime}, + }; + use candid::Nat; + use ic_canister_runtime::{IcError, Runtime}; + use icrc_ledger_types::{ + icrc1::{account::Account, transfer::BlockIndex}, + icrc2::transfer_from::{TransferFromArgs, TransferFromError}, + }; + use sol_rpc_types::{CommitmentLevel, MultiRpcResult}; + + type TransferFromResult = Result; + + #[tokio::test] + async fn should_answer_the_call_matching_an_expectation() { + let runtime = TestCanisterRuntime::new() + .expect_icrc2_transfer_from(transfer_from_args(7), Ok(BlockIndex::from(42_u64))); + + let response: TransferFromResult = transfer_from(&runtime, transfer_from_args(7)) + .await + .expect("the call should be answered"); + + assert_eq!(response, Ok(BlockIndex::from(42_u64))); + } + + #[tokio::test] + async fn should_fail_the_call_with_the_expected_error() { + let runtime = TestCanisterRuntime::new() + .expect_icrc2_transfer_from(transfer_from_args(7), IcError::CallPerformFailed); + + let response = transfer_from(&runtime, transfer_from_args(7)).await; + + assert_eq!(response, Err(IcError::CallPerformFailed)); + } + + #[tokio::test] + #[should_panic(expected = "No matching expectation found")] + async fn should_panic_on_a_call_without_a_matching_expectation() { + let runtime = TestCanisterRuntime::new(); + + let _ = transfer_from(&runtime, transfer_from_args(7)).await; + } + + #[tokio::test] + #[should_panic(expected = "No matching expectation found")] + async fn should_panic_on_a_call_with_unexpected_arguments() { + let runtime = TestCanisterRuntime::new() + .expect_icrc2_transfer_from(transfer_from_args(7), Ok(BlockIndex::from(42_u64))); + + let _ = transfer_from(&runtime, transfer_from_args(8)).await; + } + + #[test] + #[should_panic(expected = "fewer than expected")] + fn should_panic_on_an_expectation_that_is_never_called() { + let runtime = TestCanisterRuntime::new().expect_get_slot( + CommitmentLevel::Finalized, + MultiRpcResult::Consistent(Ok(42)), + ); + + drop(runtime); + } + + async fn transfer_from( + runtime: &TestCanisterRuntime, + args: TransferFromArgs, + ) -> Result { + runtime + .inter_canister_call_runtime() + .update_call(ledger_canister_id(), "icrc2_transfer_from", (args,), 0) + .await + } + + fn transfer_from_args(amount: u64) -> TransferFromArgs { + TransferFromArgs { + spender_subaccount: None, + from: Account::from(candid::Principal::from_slice(&[1])), + to: Account::from(candid::Principal::from_slice(&[2])), + fee: None, + created_at_time: None, + memo: None, + amount: Nat::from(amount), + } + } +} diff --git a/minter/src/withdraw/nonce/tests.rs b/minter/src/withdraw/nonce/tests.rs index 2ea10ec30..8016f780a 100644 --- a/minter/src/withdraw/nonce/tests.rs +++ b/minter/src/withdraw/nonce/tests.rs @@ -15,9 +15,10 @@ type GetAccountInfoResult = sol_rpc_types::MultiRpcResult Account { Principal::from_slice(&[1_u8; 20]).into() } +/// The `icrc2_transfer_from` arguments burning `amount` ckSOL from `from` for a withdrawal +/// to [`VALID_ADDRESS`]. +fn burn_args(from: Account, amount: u64) -> TransferFromArgs { + let to_address: solana_address::Address = VALID_ADDRESS + .parse() + .expect("BUG: VALID_ADDRESS should parse"); + TransferFromArgs { + spender_subaccount: None, + from, + to: MINTER_ACCOUNT, + fee: None, + created_at_time: None, + memo: Some(Memo::from(BurnMemo::convert(to_address)).into()), + amount: Nat::from(amount), + } +} + #[tokio::test] async fn should_return_error_if_calling_ledger_fails() { init_state(); init_schnorr_master_key(); - let runtime = TestCanisterRuntime::new().add_stub_error(IcError::CallPerformFailed); + let runtime = TestCanisterRuntime::new().expect_icrc2_transfer_from( + burn_args(test_caller(), MINIMUM_WITHDRAWAL_AMOUNT), + IcError::CallPerformFailed, + ); let result = withdraw( &runtime, @@ -67,9 +90,10 @@ async fn should_return_error_if_ledger_unavailable() { init_state(); init_schnorr_master_key(); - let runtime = TestCanisterRuntime::new().add_stub_response(Err::( - TransferFromError::TemporarilyUnavailable, - )); + let runtime = TestCanisterRuntime::new().expect_icrc2_transfer_from( + burn_args(test_caller(), MINIMUM_WITHDRAWAL_AMOUNT), + Err(TransferFromError::TemporarilyUnavailable), + ); let result = withdraw( &runtime, @@ -92,11 +116,12 @@ async fn should_return_error_if_insufficient_allowance() { init_state(); init_schnorr_master_key(); - let runtime = TestCanisterRuntime::new().add_stub_response(Err::( - TransferFromError::InsufficientAllowance { + let runtime = TestCanisterRuntime::new().expect_icrc2_transfer_from( + burn_args(test_caller(), MINIMUM_WITHDRAWAL_AMOUNT), + Err(TransferFromError::InsufficientAllowance { allowance: Nat::from(123u64), - }, - )); + }), + ); let result = withdraw( &runtime, @@ -117,11 +142,12 @@ async fn should_return_error_if_insufficient_funds() { init_state(); init_schnorr_master_key(); - let runtime = TestCanisterRuntime::new().add_stub_response(Err::( - TransferFromError::InsufficientFunds { + let runtime = TestCanisterRuntime::new().expect_icrc2_transfer_from( + burn_args(test_caller(), MINIMUM_WITHDRAWAL_AMOUNT), + Err(TransferFromError::InsufficientFunds { balance: Nat::from(123u64), - }, - )); + }), + ); let result = withdraw( &runtime, @@ -142,12 +168,13 @@ async fn should_return_temporarily_unavailable_on_generic_error() { init_state(); init_schnorr_master_key(); - let runtime = TestCanisterRuntime::new().add_stub_response(Err::( - TransferFromError::GenericError { + let runtime = TestCanisterRuntime::new().expect_icrc2_transfer_from( + burn_args(test_caller(), MINIMUM_WITHDRAWAL_AMOUNT), + Err(TransferFromError::GenericError { error_code: Nat::from(123u64), message: "msg".to_string(), - }, - )); + }), + ); let result = withdraw( &runtime, @@ -171,7 +198,10 @@ async fn should_return_ok_if_burn_succeeds() { init_schnorr_master_key(); let runtime = TestCanisterRuntime::new() - .add_stub_response(Ok::(Nat::from(123u64))) + .expect_icrc2_transfer_from( + burn_args(test_caller(), MINIMUM_WITHDRAWAL_AMOUNT), + Ok(Nat::from(123u64)), + ) .with_increasing_time(); let result = withdraw( @@ -340,7 +370,7 @@ mod process_pending_withdrawals_tests { create_withdrawal_batch_transaction, create_withdrawal_batch_transaction_on, submit_withdrawal_batch_transaction, }, - nonce_account_info, succeeded_withdrawal_response, + minter_signature_nth, nonce_account_info, succeeded_withdrawal_response, }, }; @@ -419,7 +449,10 @@ mod process_pending_withdrawals_tests { let burn_block_index = 3_u64; let result = withdraw( &TestCanisterRuntime::new() - .add_stub_response(Ok::(Nat::from(burn_block_index))) + .expect_icrc2_transfer_from( + burn_args(test_caller(), minter_balance + WITHDRAWAL_FEE), + Ok(Nat::from(burn_block_index)), + ) .with_increasing_time(), test_caller(), minter_balance + WITHDRAWAL_FEE, @@ -462,12 +495,14 @@ mod process_pending_withdrawals_tests { let events_before = EventsAssert::from_recorded(); let runtime = TestCanisterRuntime::new() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 1), - )))) - .add_stub_response(SendTransactionResult::Consistent(Ok( - minter_signature().into() - ))) + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ) + .expect_send_transaction_skipping_preflight( + minter_signature(), + SendTransactionResult::Consistent(Ok(minter_signature().into())), + ) .add_signer(sign_as_minter()) .with_increasing_time(); @@ -494,12 +529,14 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 1), - )))) - .add_stub_response(SendTransactionResult::Consistent(Ok( - minter_signature().into() - ))) + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ) + .expect_send_transaction_skipping_preflight( + minter_signature(), + SendTransactionResult::Consistent(Ok(minter_signature().into())), + ) .add_signer(sign_as_minter()); process_pending_withdrawals(runtime).await; @@ -555,50 +592,58 @@ mod process_pending_withdrawals_tests { devnet_sweep::init_balance(); events::accept_withdrawal(account(1), 1, MINIMUM_WITHDRAWAL_AMOUNT); let minter_address = devnet_sweep::minter_main_address(); - let signature = devnet_sweep::minter_signature_of( - &build_batch_withdrawal_message( - &minter_address, - &NONCE_ACCOUNT, - durable_nonce(1), - &[( - solana_address::Address::from([0u8; 32]), - MINIMUM_WITHDRAWAL_AMOUNT - WITHDRAWAL_FEE, - )], - ) - .unwrap(), - ); + let message = build_batch_withdrawal_message( + &minter_address, + &NONCE_ACCOUNT, + durable_nonce(1), + &[( + solana_address::Address::from([0u8; 32]), + MINIMUM_WITHDRAWAL_AMOUNT - WITHDRAWAL_FEE, + )], + ) + .unwrap(); + let signature = devnet_sweep::minter_signature_of(&message); + let sent_transaction = solana_transaction::Transaction { + signatures: vec![signature], + message, + }; let submission = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(minter_address, 1), - )))) - .add_stub_response(SendTransactionResult::Consistent(Err( - RpcError::ValidationError("send failed".to_string()), - ))) + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(minter_address, 1)))), + ) + .expect_send_exact_transaction_skipping_preflight( + sent_transaction.clone(), + SendTransactionResult::Consistent(Err(RpcError::ValidationError( + "send failed".to_string(), + ))), + ) .add_signer(sign_as_minter().expect([Ok(signature)])); process_pending_withdrawals(submission.clone()).await; assert_matches!(withdrawal_status(1), WithdrawSolStatus::TxSent { .. }); let rebroadcast = TestCanisterRuntime::new() .with_increasing_time_from(submission.time() + MIN_REBROADCAST_AGE.as_nanos() as u64) - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(minter_address, 1), - )))) - .add_stub_response(SendTransactionResult::Consistent(Ok(signature.into()))); - finalize_transactions(rebroadcast.clone()).await; - assert_eq!( - rebroadcast.sent_transactions(), - submission.sent_transactions() - ); + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(minter_address, 1)))), + ) + .expect_send_exact_transaction_skipping_preflight( + sent_transaction, + SendTransactionResult::Consistent(Ok(signature.into())), + ); + finalize_transactions(rebroadcast).await; assert_matches!(withdrawal_status(1), WithdrawSolStatus::TxSent { .. }); let finalization = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(minter_address, 2), - )))) - .add_stub_response(succeeded_withdrawal_response(&signature)); + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(minter_address, 2)))), + ) + .expect_get_transaction(signature, succeeded_withdrawal_response(&signature)); finalize_transactions(finalization).await; assert_eq!( withdrawal_status(1), @@ -620,9 +665,12 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Err( - RpcError::ValidationError("account unavailable".to_string()), - ))); + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Err(RpcError::ValidationError( + "account unavailable".to_string(), + ))), + ); process_pending_withdrawals(runtime).await; @@ -647,9 +695,10 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 1), - )))); + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ); process_pending_withdrawals(runtime).await; @@ -678,9 +727,10 @@ mod process_pending_withdrawals_tests { let failing_runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 1), - )))) + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ) .add_signer( sign_as_minter() .of_message(expected_message.serialize()) @@ -705,9 +755,10 @@ mod process_pending_withdrawals_tests { let recovering_runtime = TestCanisterRuntime::new() .with_increasing_time() .add_signer(sign_as_minter().of_message(expected_message.serialize())) - .add_stub_response(SendTransactionResult::Consistent(Ok( - minter_signature().into() - ))); + .expect_send_transaction_skipping_preflight( + minter_signature(), + SendTransactionResult::Consistent(Ok(minter_signature().into())), + ); process_pending_withdrawals(recovering_runtime).await; @@ -741,14 +792,22 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 1), - )))) - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 2), - )))) - .add_stub_response(SendTransactionResult::Consistent(Ok(signature(1).into()))) - .add_stub_response(SendTransactionResult::Consistent(Ok(signature(2).into()))) + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ) + .expect_get_account_info( + second_nonce_account, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 2)))), + ) + .expect_send_transaction_skipping_preflight( + minter_signature_nth(0), + SendTransactionResult::Consistent(Ok(minter_signature_nth(0).into())), + ) + .expect_send_transaction_skipping_preflight( + minter_signature_nth(1), + SendTransactionResult::Consistent(Ok(minter_signature_nth(1).into())), + ) .add_signer(sign_as_minter().times(2)); process_pending_withdrawals(runtime).await; @@ -814,12 +873,11 @@ mod process_pending_withdrawals_tests { let runtime = (0..MAX_CONCURRENT_SIGNATURES) .fold(TestCanisterRuntime::new(), |runtime, i| { - runtime - .add_stub_response(SendTransactionResult::Consistent(Ok(signature(i).into()))) + runtime.expect_send_transaction_skipping_preflight( + minter_signature_nth(i), + SendTransactionResult::Consistent(Ok(minter_signature_nth(i).into())), + ) }) - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, num_bound), - )))) .with_increasing_time() .add_signer(sign_as_minter().times(MAX_CONCURRENT_SIGNATURES)); @@ -860,12 +918,14 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 1), - )))) - .add_stub_response(SendTransactionResult::Consistent(Ok( - minter_signature().into() - ))) + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ) + .expect_send_transaction_skipping_preflight( + minter_signature(), + SendTransactionResult::Consistent(Ok(minter_signature().into())), + ) .add_signer(sign_as_minter()); process_pending_withdrawals(runtime.clone()).await; @@ -901,15 +961,20 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 1), - )))) - .add_stub_response(GetAccountInfoResult::Consistent(Err( - RpcError::ValidationError("account unavailable".to_string()), - ))) - .add_stub_response(SendTransactionResult::Consistent(Ok( - minter_signature().into() - ))) + .expect_get_account_info( + second_nonce_account, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ) + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Err(RpcError::ValidationError( + "account unavailable".to_string(), + ))), + ) + .expect_send_transaction_skipping_preflight( + minter_signature(), + SendTransactionResult::Consistent(Ok(minter_signature().into())), + ) .add_signer(sign_as_minter()); process_pending_withdrawals(runtime.clone()).await; @@ -935,9 +1000,12 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Err( - RpcError::ValidationError("account unavailable".to_string()), - ))); + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Err(RpcError::ValidationError( + "account unavailable".to_string(), + ))), + ); process_pending_withdrawals(runtime.clone()).await; @@ -955,9 +1023,10 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 1), - )))) + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ) .add_signer(sign_as_minter().expect([Err(SignCallError::CallFailed( CallRejected::with_rejection(4, "signing service unavailable".to_string()).into(), ))])); @@ -991,12 +1060,14 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some( - nonce_account_info(MINTER_ADDRESS, 1), - )))) - .add_stub_response(SendTransactionResult::Consistent(Ok( - minter_signature().into() - ))) + .expect_get_account_info( + NONCE_ACCOUNT, + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ) + .expect_send_transaction_skipping_preflight( + minter_signature(), + SendTransactionResult::Consistent(Ok(minter_signature().into())), + ) .add_signer(sign_as_minter()); process_pending_withdrawals(runtime.clone()).await; From 30a386a21c146db404ae367c5d8e2e2500bb52b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 8 Oct 2026 14:48:44 +0000 Subject: [PATCH 2/2] test(minter): suspend mocked inter-canister calls before answering them Co-Authored-By: Claude Opus 5.5 --- minter/src/test_fixtures/runtime.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index a21ccb2e0..b9120f8ea 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -513,8 +513,9 @@ impl Runtime for MockRuntime { method.to_string(), CandidArgs::of(args), cycles, - )?; - Ok(Decode!(&response, Out).expect("BUG: failed to decode Candid response")) + ); + suspend_like_an_inter_canister_call().await; + Ok(Decode!(&response?, Out).expect("BUG: failed to decode Candid response")) } async fn query_call( @@ -532,6 +533,7 @@ impl Runtime for MockRuntime { .lock() .unwrap() .query_call(id, method.to_string(), CandidArgs::of(args)); + suspend_like_an_inter_canister_call().await; Ok(Decode!(&response?, Out).expect("BUG: failed to decode Candid response")) } }