diff --git a/rs/ethereum/cketh/minter/tests/deposit_from_cex.rs b/rs/ethereum/cketh/minter/tests/deposit_from_cex.rs index 7cad3a662043..1800b6914c4d 100644 --- a/rs/ethereum/cketh/minter/tests/deposit_from_cex.rs +++ b/rs/ethereum/cketh/minter/tests/deposit_from_cex.rs @@ -8,7 +8,6 @@ //! principal and subaccount. use assert_matches::assert_matches; -use candid::Principal; use ic_cketh_minter::balance_scan::batcher::{ BalanceOfCall, decode_balance_batch, encode_balance_batch, }; @@ -19,13 +18,10 @@ use ic_cketh_minter::numeric::Erc20Value; use ic_cketh_test_utils::anvil::{ Anvil, DEV_ACCOUNT, SentTransaction, address_from_hex, deploy_mock_erc20, }; -use ic_cketh_test_utils::ckerc20::Erc20Token; -use ic_cketh_test_utils::live::{Holding, LiveSetup, contract_address}; -use ic_cketh_test_utils::{MINTER_ADDRESS, SWEEPER_ADDRESS}; +use ic_cketh_test_utils::ckerc20::{CkErc20Setup, Erc20Token}; +use ic_cketh_test_utils::live::{CexDeposit, DepositPlan, LiveSetup}; +use ic_cketh_test_utils::{CkEthSetup, SWEEPER_ADDRESS}; use ic_ethereum_types::Address; -use icrc_ledger_types::icrc1::account::Account; -use std::collections::BTreeSet; -use std::str::FromStr; #[test] fn should_read_erc20_balances_across_tokens_and_holders() { @@ -179,107 +175,81 @@ fn should_revert_the_whole_call_when_a_token_is_not_a_contract() { /// periodic balance scan makes genuine outcalls through the IC's HTTPS-outcalls feature — reaching /// anvil over HTTP — and reads real ERC-20 balances from it. /// -/// Three independent depositors each fund a single token — 20 USDT, 15 USDC and 1 USDT — so the -/// scan reads several addresses and tokens and must apply the per-token minimum to each. Only the -/// two at-or-above-minimum deposits are flagged as candidates; the 1 USDT deposit is scanned but, -/// being below the ~$10 minimum, is not. +/// Three independent depositors each fund a single token — USDT at twice its minimum, USDC at +/// exactly its minimum, and USDT at a tenth of it, every amount derived from the minimum the +/// minter itself reports through `get_minter_info` — so the scan reads several addresses and +/// tokens and must apply the per-token minimum to each. Only the two at-or-above-minimum deposits +/// are flagged as candidates; the below-minimum deposit is scanned but not flagged. #[test] fn should_flag_only_deposits_at_or_above_the_per_token_minimum() { const DEPOSIT_SUBACCOUNT: [u8; 32] = [42; 32]; - // 6-decimal amounts; ckUSDC and ckUSDT share a 10_000_000 (~$10) candidate minimum. - const USDT_ABOVE_MINIMUM: u128 = 20_000_000; - const USDC_ABOVE_MINIMUM: u128 = 15_000_000; - const USDT_BELOW_MINIMUM: u128 = 1_000_000; - - let setup = LiveSetup::new_balance_scan(); - // `supported_erc20_tokens()` registers ckUSDC then ckUSDT, in that order. - let [usdc, usdt] = setup.supported_erc20_tokens() else { - panic!("expected exactly 2 supported tokens") - }; - let deposits = [ - (setup.depositor(1), usdt, USDT_ABOVE_MINIMUM), - (setup.depositor(2), usdc, USDC_ABOVE_MINIMUM), - (setup.depositor(3), usdt, USDT_BELOW_MINIMUM), - ]; - let holdings: Vec> = deposits - .iter() - .map(|&(depositor, token, amount)| Holding { - deposit: setup.register_deposit_address(depositor, DEPOSIT_SUBACCOUNT, token), - token, - amount, - }) - .collect(); - setup.credit_deposits(&holdings); + let setup = LiveSetup::::new(); + // `supported_erc20_tokens_owned()` registers ckUSDC then ckUSDT, in that order. + let [usdc, usdt]: [Erc20Token; 2] = setup + .supported_erc20_tokens_owned() + .try_into() + .expect("expected exactly 2 supported tokens"); + let usdt_minimum = setup.minimum_deposit_amount(&usdt); + let usdt_above_minimum = 2 * usdt_minimum; + let usdc_at_minimum = setup.minimum_deposit_amount(&usdc); + let usdt_below_minimum = usdt_minimum / 10; + let plans = [ + (setup.depositor(1), usdt.clone(), usdt_above_minimum), + (setup.depositor(2), usdc.clone(), usdc_at_minimum), + (setup.depositor(3), usdt.clone(), usdt_below_minimum), + ] + .map(|(owner, token, amount)| DepositPlan { + owner, + subaccount: DEPOSIT_SUBACCOUNT, + token, + amount, + }); + + let (setup, deposits) = setup + .call_minter_deposit_erc20(plans) + .expect_deposit_responses(); + let setup = setup + .credit_deposits_from_cex(&deposits) + .expect_deposit_balances_on_anvil() + .setup; assert_matches!( - setup.await_scan(setup.depositor(1), DEPOSIT_SUBACCOUNT, usdt).status, + setup.await_scan(setup.depositor(1), DEPOSIT_SUBACCOUNT, &usdt).status, DepositStatus::AwaitingSweep(detected) if detected.erc20_contract_address == usdt.contract.address - && detected.scanned_balance == USDT_ABOVE_MINIMUM + && detected.scanned_balance == usdt_above_minimum && detected.detected_at_block > 0_u8 ); assert_matches!( - setup.await_scan(setup.depositor(2), DEPOSIT_SUBACCOUNT, usdc).status, + setup.await_scan(setup.depositor(2), DEPOSIT_SUBACCOUNT, &usdc).status, DepositStatus::AwaitingSweep(detected) if detected.erc20_contract_address == usdc.contract.address - && detected.scanned_balance == USDC_ABOVE_MINIMUM + && detected.scanned_balance == usdc_at_minimum && detected.detected_at_block > 0_u8 ); assert_matches!( - setup.await_scan(setup.depositor(3), DEPOSIT_SUBACCOUNT, usdt).status, + setup.await_scan(setup.depositor(3), DEPOSIT_SUBACCOUNT, &usdt).status, DepositStatus::Scanning { scan_count, last_scanned_block, .. } if scan_count >= 1 && last_scanned_block.is_some() ); } -/// A budget, not a cost: driving stops the moment the transfer lands, so this only has to be more -/// ticks than the run needs. One sends the transfer; the spares cover a tick landing before the -/// funding task has burned, and a tick lost to an outcall the jump timed out. -const FUNDING_TICKS: u32 = 6; - #[test] fn should_fund_the_sweeper_address_by_burning_cketh_from_the_fee_account() { - let setup = LiveSetup::new_funding(); - + let setup = LiveSetup::::new() + .fund_fee_account() + .expect_fee_account_credited(); // Only the fee account is funded: sweep gas must come from there and nowhere else. Read before // the minter is armed, since the funding decision reads nothing off the chain and so its first // run burns within milliseconds of the upgrade below — far too fast to snapshot after it. - let supply_before = setup.cketh_total_supply(); - let fee_account_before = setup.cketh_balance_of(setup.fee_account()); - let minter_eth_before = setup.anvil_eth_balance(&setup.minter_address()); - - setup.upgrade_minter(); - let sweeper = setup.await_sweeper_address(); - assert_eq!( - setup.anvil_eth_balance(&sweeper), - 0, - "the sweeper address must start empty, so any balance proves the funding landed" - ); - - let received = setup.await_eth_received(&sweeper, FUNDING_TICKS); - - let burned = supply_before - .checked_sub(setup.cketh_total_supply()) - .expect("the funding must have burned ckETH, not minted it"); - assert!(burned > 0, "funding must burn ckETH"); - assert_eq!( - fee_account_before - setup.cketh_balance_of(setup.fee_account()), - burned, - "the burn must be debited from the fee account" - ); - - // The ETH moved, and never more than was burned — the backing invariant, observed end to end. - let spent = minter_eth_before - setup.anvil_eth_balance(&setup.minter_address()); - assert!( - received > 0 && received < burned, - "the sweeper receives the burned amount minus the fee, got received={received} burned={burned}" - ); - assert!( - spent <= burned, - "the ETH debited from the main address ({spent}) must never exceed the ckETH \ - burned for it ({burned})" - ); + let baseline = setup.funding_baseline(); + setup + .upgrade_minter() + .expect_sweeper_address(&address_from_hex(SWEEPER_ADDRESS)) + .expect_sweeper_starts_empty() + .expect_eth_received() + .expect_funding_backed_by_burn(&baseline); } #[test] @@ -287,179 +257,168 @@ fn should_credit_twenty_cex_deposits_through_one_sweep_per_token() { /// Ten depositors per token, so each sweep is a ten-deposit single-token batch — directly /// comparable with `deposit_from_cex_demo`'s measured scenarios. const DEPOSITORS_PER_TOKEN: u64 = 10; - // 6-decimal amounts, both far above the ~$10 per-token candidate minimum. - const USDC_DEPOSIT: u128 = 100_000_000; - const USDT_DEPOSIT: u128 = 150_000_000; - let sweeper = address_from_hex(SWEEPER_ADDRESS); - let setup = LiveSetup::new_sweep(); + let setup = LiveSetup::::new() + .fund_fee_account() + .expect_fee_account_credited() + .upgrade_minter() + .expect_sweeper_address_derived() + .expect_eth_received() + .expect_funding_finalized(); + + let sweeper = setup.await_sweeper_address(); let funded_gas = setup.anvil_eth_balance(&sweeper); - let contracts = setup.sweep_contracts(); - let [usdc, usdt] = setup.supported_erc20_tokens() else { - panic!("expected exactly 2 supported tokens") - }; + let delegate = setup.sweep_contracts().delegate; + let [usdc, usdt]: [Erc20Token; 2] = setup + .supported_erc20_tokens_owned() + .try_into() + .expect("expected exactly 2 supported tokens"); + let usdc_deposit = 10 * setup.minimum_deposit_amount(&usdc); + let usdt_deposit = 15 * setup.minimum_deposit_amount(&usdt); // Every depositor gets a distinct principal and a distinct subaccount, so no two share a // deposit address and each attestation binds a different account. - let deposits: Vec = (0..2 * DEPOSITORS_PER_TOKEN) + let plans: Vec = (0..2 * DEPOSITORS_PER_TOKEN) .map(|index| { let (token, amount) = if index < DEPOSITORS_PER_TOKEN { - (usdc, USDC_DEPOSIT) + (usdc.clone(), usdc_deposit) } else { - (usdt, USDT_DEPOSIT) + (usdt.clone(), usdt_deposit) }; - let owner = setup.depositor(index); - let subaccount = [u8::try_from(index).unwrap(); 32]; - Deposit { - owner, - subaccount, + DepositPlan { + owner: setup.depositor(index), + subaccount: [u8::try_from(index).unwrap(); 32], token, amount, - address: setup.register_deposit_address(owner, subaccount, token), } }) .collect(); - let distinct: BTreeSet<_> = deposits.iter().map(|deposit| deposit.address).collect(); - assert_eq!( - distinct.len(), - deposits.len(), - "every account must get its own deposit address" - ); - for deposit in &deposits { - assert!( - setup.anvil().code(&deposit.address).is_empty(), - "a deposit address starts with no code" - ); - assert_eq!( - setup.anvil().balance(&deposit.address), - 0, - "a deposit address never needs ETH of its own" - ); - } + let (setup, deposits) = setup + .call_minter_deposit_erc20(plans) + .expect_deposit_responses(); + let setup = setup.assert_deposit_addresses_bare(&deposits); // The CEX withdrawals: a plain ERC-20 transfer to each address, carrying no principal. - let holdings: Vec> = deposits.iter().map(Deposit::holding).collect(); - setup.credit_deposits(&holdings); - - for deposit in &deposits { - assert_matches!( - setup.await_scan(deposit.owner, deposit.subaccount, deposit.token).status, - DepositStatus::AwaitingSweep(detected) if detected.scanned_balance == deposit.amount - ); - } + let setup = setup + .credit_deposits_from_cex(&deposits) + .expect_deposit_balances_on_anvil() + .expect_each_awaiting_sweep(); // One sweep per token, and nothing more. - let sweeps = setup.await_sweeps(&sweeper, 2); - for sweep in &sweeps { - assert_eq!( - sweep.transaction_type, 4, - "a first sweep installs delegations, so it must be an EIP-7702 transaction: {sweep:?}" - ); - assert!(sweep.succeeded, "the sweep reverted: {sweep:?}"); - } + let (setup, sweeps) = setup + .await_sweeps(&sweeper, 2) + .expect_all_delegating_sweeps(); assert_sweep_gas_near_demo(&sweeps, DEPOSITORS_PER_TOKEN); - // What each sweep actually batched, so that two transactions cannot pass as one per token. - let mut batched: Vec<(Address, usize)> = setup - .minter_events() - .into_iter() - .filter_map(|event| match event.payload { - EventPayload::AcceptedSweepRequest { token, items, .. } => Some(( - Address::from_str(&token).expect("BUG: the sweep names an invalid token"), - items.len(), - )), - _ => None, - }) - .collect(); - batched.sort(); - let per_token = usize::try_from(DEPOSITORS_PER_TOKEN).unwrap(); - let mut expected = vec![ - (contract_address(usdc), per_token), - (contract_address(usdt), per_token), - ]; - expected.sort(); - assert_eq!( - batched, expected, - "each sweep must batch one token's ten deposits, not a mixed batch and a redundant one" - ); + let setup = setup + .assert_sweeps_batched_per_token(&deposits) + .assert_addresses_swept_empty(&deposits) + .assert_minter_holds_swept_totals(&deposits) + .assert_delegations_installed(&deposits, &delegate) + .assert_sweeper_spent_gas(&sweeper, funded_gas); - // The funds left every deposit address and landed at the minter's main address. - let minter = address_from_hex(MINTER_ADDRESS); - for deposit in &deposits { - assert_eq!( - setup - .anvil() - .erc20_balance(&contract_address(deposit.token), &deposit.address), - Erc20Value::from(0_u8), - "the deposit address should have been swept empty" - ); - } - for (token, amount) in [ - (usdc, USDC_DEPOSIT * u128::from(DEPOSITORS_PER_TOKEN)), - (usdt, USDT_DEPOSIT * u128::from(DEPOSITORS_PER_TOKEN)), - ] { - assert_eq!( - setup - .anvil() - .erc20_balance(&contract_address(token), &minter), - Erc20Value::from(amount), - "the minter's main address should hold everything swept of {}", - token.contract.address - ); - } + setup.expect_mints(&deposits); +} - // Every address is now delegated, by the 23-byte EIP-7702 designator `0xef0100 || delegate`. - let mut designator = vec![0xef, 0x01, 0x00]; - designator.extend_from_slice(contracts.delegate.as_ref()); - for deposit in &deposits { - assert_eq!( - setup.anvil().code(&deposit.address), - designator, - "the sweep should have installed the delegation" - ); - } - assert!( - setup.anvil().balance(&sweeper) < funded_gas, - "the sweeper address pays for the sweeps out of its own prepaid gas" +#[test] +fn should_sweep_a_second_deposit_despite_resending_a_stale_authorization() { + const DEPOSIT_SUBACCOUNT: [u8; 32] = [7; 32]; + + let setup = LiveSetup::::new() + .fund_fee_account() + .expect_fee_account_credited() + .upgrade_minter() + .expect_sweeper_address_derived() + .expect_eth_received() + .expect_funding_finalized(); + + let sweeper = setup.await_sweeper_address(); + let delegate = setup.sweep_contracts().delegate; + let [usdc, _usdt]: [Erc20Token; 2] = setup + .supported_erc20_tokens_owned() + .try_into() + .expect("expected exactly 2 supported tokens"); + let usdc_minimum = setup.minimum_deposit_amount(&usdc); + let owner = setup.depositor(1); + + let (setup, first_deposits) = setup + .call_minter_deposit_erc20([DepositPlan { + owner, + subaccount: DEPOSIT_SUBACCOUNT, + token: usdc.clone(), + amount: 3 * usdc_minimum, + }]) + .expect_deposit_responses(); + let setup = setup + .credit_deposits_from_cex(&first_deposits) + .expect_deposit_balances_on_anvil() + .expect_each_awaiting_sweep(); + let (setup, _first_sweeps) = setup + .await_sweeps(&sweeper, 1) + .expect_all_delegating_sweeps(); + let setup = setup + .expect_sweeps_finalized(1) + .expect_mints(&first_deposits); + let address = first_deposits[0].address; + assert_eq!( + setup.anvil().transaction_count(&address), + 1, + "applying the first sweep's authorization must spend the deposit address' nonce 0" ); - // Only now the mint, which the unchanged deposit pipeline drives off each sweep's own helper - // event — downstream of every effect asserted above. - let ledgers = [ - (usdc, setup.ckerc20_token("ckUSDC").ledger_canister_id), - (usdt, setup.ckerc20_token("ckUSDT").ledger_canister_id), - ]; - for deposit in &deposits { - let (_, ledger_id) = ledgers - .iter() - .find(|(token, _)| token.contract.address == deposit.token.contract.address) - .expect("every deposited token has a ledger"); - let account = Account { - owner: deposit.owner, - subaccount: Some(deposit.subaccount), - }; - setup.await_credited(*ledger_id, account, deposit.amount); - } -} + let second_deposits = [CexDeposit { + amount: 2 * usdc_minimum, + ..first_deposits[0].clone() + }]; + let setup = setup + .credit_deposits_from_cex(&second_deposits) + .expect_deposit_balances_on_anvil() + .setup; + let (setup, second_registrations) = setup + .call_minter_deposit_erc20([DepositPlan { + owner, + subaccount: DEPOSIT_SUBACCOUNT, + token: usdc.clone(), + amount: second_deposits[0].amount, + }]) + .expect_deposit_responses(); + assert_eq!( + second_registrations[0].address, address, + "re-registering the pair must yield the same deposit address" + ); + assert_matches!( + setup.await_detection(owner, DEPOSIT_SUBACCOUNT, &usdc).status, + DepositStatus::AwaitingSweep(detected) if detected.scanned_balance == second_deposits[0].amount + ); -/// One user's deposit: who it credits, which token, and the address the CEX sends to. -struct Deposit<'a> { - owner: Principal, - subaccount: [u8; 32], - token: &'a Erc20Token, - amount: u128, - address: Address, -} + let (setup, sweeps) = setup + .await_sweeps(&sweeper, 2) + .expect_all_delegating_sweeps(); + let second_sweep = &sweeps[1]; + assert_eq!( + setup.anvil().authorization_nonces(&second_sweep.hash), + vec![0], + "the re-sent authorization still names nonce 0, stale now that the address is at nonce 1" + ); + assert_eq!( + setup.anvil().transaction_count(&address), + 1, + "a skipped stale authorization must not advance the deposit address' nonce" + ); -impl<'a> Deposit<'a> { - fn holding(&self) -> Holding<'a> { - Holding { - deposit: self.address, - token: self.token, - amount: self.amount, - } - } + let all_deposits = [first_deposits[0].clone(), second_deposits[0].clone()]; + let setup = setup + .assert_delegations_installed(&all_deposits, &delegate) + .assert_addresses_swept_empty(&second_deposits) + .assert_minter_holds_swept_totals(&all_deposits) + .expect_mints(&all_deposits); + let mints = setup + .minter_events() + .into_iter() + .filter(|event| matches!(event.payload, EventPayload::MintedCkErc20 { .. })) + .count(); + assert_eq!(mints, 2, "each deposit flow must be credited exactly once"); } fn assert_sweep_gas_near_demo(sweeps: &[SentTransaction], deposits_per_sweep: u64) { diff --git a/rs/ethereum/cketh/test_utils/src/anvil.rs b/rs/ethereum/cketh/test_utils/src/anvil.rs index ecd01308f43f..d7fe06d75f7f 100644 --- a/rs/ethereum/cketh/test_utils/src/anvil.rs +++ b/rs/ethereum/cketh/test_utils/src/anvil.rs @@ -379,6 +379,26 @@ impl Anvil { .unwrap_or_else(|e| panic!("not a u64 transaction count {count}: {e}")) } + pub fn authorization_nonces(&self, tx_hash: &str) -> Vec { + let transaction = self.rpc("eth_getTransactionByHash", serde_json::json!([tx_hash])); + assert!( + !transaction.is_null(), + "no transaction {tx_hash} on the chain" + ); + transaction["authorizationList"] + .as_array() + .unwrap_or_else(|| panic!("transaction {tx_hash} carries no authorization list")) + .iter() + .map(|tuple| { + let nonce = tuple["nonce"].as_str().unwrap_or_else(|| { + panic!("authorization tuple of {tx_hash} has no hex nonce: {tuple}") + }); + u64::from_str_radix(nonce.trim_start_matches("0x"), 16) + .unwrap_or_else(|e| panic!("not a u64 nonce {nonce}: {e}")) + }) + .collect() + } + /// Credits `address` with `wei` of ETH (foundry's `anvil_setBalance`). The minter's sweeper /// address is funded this way rather than through the ckETH burn-and-withdraw pipeline, which is /// a separate concern from sweeping. @@ -622,6 +642,12 @@ fn decode_address(data: &[u8]) -> Address { ) } +pub fn delegation_designator(delegate: &Address) -> Vec { + let mut designator = vec![0xef, 0x01, 0x00]; + designator.extend_from_slice(delegate.as_ref()); + designator +} + /// What a transaction the harness went looking for actually did on chain. #[derive(Clone, Eq, PartialEq, Debug)] pub struct SentTransaction { diff --git a/rs/ethereum/cketh/test_utils/src/lib.rs b/rs/ethereum/cketh/test_utils/src/lib.rs index 6f6273294a41..d2172eb5b9cc 100644 --- a/rs/ethereum/cketh/test_utils/src/lib.rs +++ b/rs/ethereum/cketh/test_utils/src/lib.rs @@ -102,9 +102,8 @@ pub const USDC_ERC20_CONTRACT_ADDRESS_LOWERCASE: &str = pub const MINTER_ADDRESS: &str = "0x30a14171b7c4c93ff5213f82eeb74f7c7e3f1ebc"; /// The minter's dedicated sweeper address, derived from the same test key as [`MINTER_ADDRESS`] /// under the sweeper derivation path. Hardcoded as the value that derivation is expected to -/// produce, so a test can name it before the minter is installed; `MinterInfo::sweeper_address` -/// reports what the running minter actually derived. A test that funds it asserts the sweep really -/// was sent from here, so a stale value fails loudly. +/// produce; the `get_minter_info` tests assert it against the `MinterInfo::sweeper_address` the +/// running minter actually derived, so a stale value fails loudly. pub const SWEEPER_ADDRESS: &str = "0x07e326c6604e3801270fc52ffb7ad3d6c5dfe89c"; pub const DEFAULT_WITHDRAWAL_DESTINATION_ADDRESS: &str = "0x221E931fbFcb9bd54DdD26cE6f5e29E98AdD01C0"; diff --git a/rs/ethereum/cketh/test_utils/src/live.rs b/rs/ethereum/cketh/test_utils/src/live.rs index 646b4063c118..a23038829614 100644 --- a/rs/ethereum/cketh/test_utils/src/live.rs +++ b/rs/ethereum/cketh/test_utils/src/live.rs @@ -5,9 +5,7 @@ //! [`LiveSetup`] is generic over the fixture it wraps, so the facilities every live test needs live //! in one place: buying minter time, depositing through the production helper contract, reading the //! minter's canister log, and arranging state on anvil. The flavours differ only in what they build -//! and seed — [`LiveSetup::new_balance_scan`] for the ckERC20 balance scan, -//! [`LiveSetup::new_funding`] for sweeper fee funding, and [`LiveSetup::new_sweep`] for the sweep -//! of detected ckERC20 deposits. +//! and seed — one constructor per fixture type, the ckERC20 one deploying the sweep contracts. //! //! The whole fixture is built on an ordinary (non-live) PocketIC instance, exactly as the mocked //! fixtures are: `await_call` ticks deterministically, and every setup call completes in a bounded @@ -55,7 +53,7 @@ use ic_base_types::PrincipalId; use ic_cketh_minter::endpoints::events::{Event, EventPayload, TransactionStatus}; use ic_cketh_minter::endpoints::{ CkErc20Token, DepositErc20Arg, DepositErc20Error, DepositErc20Response, DepositMode, - DepositStatus, + DepositStatus, MinterInfo, }; use ic_cketh_minter::lifecycle::MinterArg; use ic_cketh_minter::lifecycle::upgrade::UpgradeArg; @@ -64,19 +62,18 @@ use ic_cketh_minter::{BALANCE_SCAN_INTERVAL, PROCESS_ETH_RETRIEVE_TRANSACTIONS_I use ic_ethereum_types::Address; use icrc_ledger_types::icrc1::account::Account; use pocket_ic::PocketIc; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; use crate::anvil::{ - Anvil, DEV_ACCOUNT, SentTransaction, SweepContracts, address_from_hex, deploy_deposit_helper, - deploy_mock_erc20, deploy_sweep_contracts, deposit_eth, erc20_balance_slot, u256_be, + Anvil, DEV_ACCOUNT, SentTransaction, SweepContracts, address_from_hex, delegation_designator, + deploy_deposit_helper, deploy_mock_erc20, deploy_sweep_contracts, deposit_eth, + erc20_balance_slot, u256_be, }; use crate::ckerc20::{CkErc20Setup, Erc20Token}; -use crate::{ - CkEthSetup, EthereumBackend, MINTER_ADDRESS, SWEEPER_ADDRESS, minter_wasm, switch_to_live, -}; +use crate::{CkEthSetup, EthereumBackend, MINTER_ADDRESS, minter_wasm, switch_to_live}; const FEE_ACCOUNT_BALANCE: u128 = 1_000_000_000_000_000_000; // 1 ckETH @@ -137,12 +134,20 @@ const CREDIT_TICKS: u32 = 6; const FUNDING_TICKS: u32 = 6; -/// A balance to place on the owned anvil node: `amount` of `token` credited to the `deposit` -/// address, so the scan reads a real balance for that (address, token) pair. -pub struct Holding<'a> { - pub deposit: Address, - pub token: &'a Erc20Token, +pub struct DepositPlan { + pub owner: Principal, + pub subaccount: [u8; 32], + pub token: Erc20Token, + pub amount: u128, +} + +#[derive(Clone)] +pub struct CexDeposit { + pub owner: Principal, + pub subaccount: [u8; 32], + pub token: Erc20Token, pub amount: u128, + pub address: Address, } /// `token.contract.address`, parsed: every canister and anvil call the harness makes needs an @@ -168,27 +173,19 @@ pub struct LiveSetup { sweep_contracts: Option, } -impl LiveSetup { - /// Starts a local anvil node and builds the full [`CkErc20Setup`] fixture against it — minter, - /// EVM RPC canister, orchestrator, and the ckUSDC/ckUSDT ledger and index canisters it spawns — - /// then switches the instance to live outcalls. - pub fn new_balance_scan() -> Self { - let anvil = Arc::new(Anvil::start_mainnet_like()); - let cketh = CkEthSetup::new(EthereumBackend::Anvil { - anvil: Arc::clone(&anvil), - sweep_contracts: None, - }); - let ckerc20 = CkErc20Setup::with_cketh(cketh).add_supported_erc20_tokens(); - Self::go_live(ckerc20, anvil) +impl Default for LiveSetup { + fn default() -> Self { + Self::new() } +} - /// Like [`Self::new_balance_scan`], but with the real deposit helper and the attested sweeper - /// delegate deployed on the node first, so the minter is installed knowing both, and with the - /// minter's sweeper address ([`SWEEPER_ADDRESS`]) funded the way production funds it: a ckETH - /// burn out of the minter's fee account, delivered by the funding pipeline — which is also - /// what lets the minter know the sweeper can pay for a sweep, since it only accepts sweeps - /// whose fee its own funding accounting covers. - pub fn new_sweep() -> Self { +impl LiveSetup { + /// Starts a local anvil node — with the real deposit helper and the attested sweeper delegate + /// deployed on it first, so the minter is installed knowing both — and builds the full + /// [`CkErc20Setup`] fixture against it: minter, EVM RPC canister, orchestrator, and the + /// ckUSDC/ckUSDT ledger and index canisters it spawns. Then switches the instance to live + /// outcalls. + pub fn new() -> Self { let anvil = Arc::new(Anvil::start_mainnet_like()); // The helper pays out to the minter's main address, which the minter only derives once // installed. It is only ever read back out of the helper event, never by the helper @@ -210,27 +207,16 @@ impl LiveSetup { ); setup.sweep_contracts = Some(contracts); setup.deposit_helper = Some(contracts.helper); - - setup.fund_fee_account(); - setup.upgrade_minter(); - let sweeper = address_from_hex(SWEEPER_ADDRESS); - assert_eq!( - setup.await_sweeper_address(), - sweeper, - "BUG: the minter derived a sweeper address other than the pinned SWEEPER_ADDRESS" - ); - setup.await_eth_received(&sweeper, FUNDING_TICKS); - setup.await_funding_finalized(); setup } /// The ckERC20 token the orchestrator spawned for `symbol`, whose ledger the mint lands on. - pub fn ckerc20_token(&self, symbol: &str) -> CkErc20Token { + fn ckerc20_token(&self, symbol: &str) -> CkErc20Token { self.fixture.find_ckerc20_token(symbol) } /// `account`'s balance on `ledger_id`, i.e. what the deposit was credited. - pub fn balance_of_ledger(&self, ledger_id: Principal, account: impl Into) -> Nat { + fn balance_of_ledger(&self, ledger_id: Principal, account: impl Into) -> Nat { self.fixture.balance_of_ledger(ledger_id, account) } @@ -241,26 +227,45 @@ impl LiveSetup { } /// The tokens the orchestrator actually registered, in registration order — the same set - /// [`Self::credit_deposits`] gives code on anvil, by construction rather than by convention. - pub fn supported_erc20_tokens(&self) -> &[Erc20Token] { + /// [`Self::credit_deposits_from_cex`] gives code on anvil, by construction rather than by + /// convention. + fn supported_erc20_tokens(&self) -> &[Erc20Token] { &self.fixture.supported_erc20_tokens } - /// Registers a `(caller/subaccount, token)` deposit and returns the Ethereum address the minter - /// derived for it (shared across the caller's tokens). - pub fn register_deposit_address( - &self, - caller: Principal, - subaccount: [u8; 32], - token: &Erc20Token, - ) -> Address { - Address::from_str(&self.deposit_erc20(caller, subaccount, token).address) - .expect("BUG: minter returned an invalid deposit address") + pub fn supported_erc20_tokens_owned(&self) -> Vec { + self.fixture.supported_erc20_tokens.clone() + } + + pub fn minimum_deposit_amount(&self, token: &Erc20Token) -> u128 { + let minimum = self + .get_minter_info() + .minimum_deposit_amounts + .expect("BUG: the minter reports no minimum deposit amounts") + .into_iter() + .find(|minimum| { + Address::from_str(&minimum.erc20_contract_address) + .expect("BUG: the minter reported an invalid token address") + == contract_address(token) + }) + .unwrap_or_else(|| { + panic!( + "BUG: the minter reports no minimum deposit amount for {}", + token.contract.address + ) + }); + let no_minimum_sentinel: Nat = Erc20Value::MAX.into(); + assert_ne!( + minimum.minimum_deposit_amount, no_minimum_sentinel, + "the minter reports no real minimum deposit amount for {}", + token.contract.address + ); + nat_to_u128(minimum.minimum_deposit_amount) } /// Calls `deposit_erc20` as `caller`, which registers (idempotently) that user's /// `(address, token)` pair for balance scanning and reports its scan progress. - pub fn deposit_erc20( + fn deposit_erc20( &self, caller: Principal, subaccount: [u8; 32], @@ -286,27 +291,27 @@ impl LiveSetup { .expect("BUG: deposit_erc20 returned an error") } - /// Places every token [`Self::supported_erc20_tokens`] returns at its real mainnet address on - /// the owned anvil node, and funds each holding with a plain ERC-20 `transfer` from a seeded + /// Places every registered token at its real mainnet address on + /// the owned anvil node, and funds each deposit with a plain ERC-20 `transfer` from a seeded /// CEX-style account — all mined in a single block, so a concurrent scan (whose batched - /// `eth_call` pins one block) sees either every holding funded or none of them. - pub fn credit_deposits(&self, holdings: &[Holding<'_>]) { + /// `eth_call` pins one block) sees either every deposit funded or none of them. + pub fn credit_deposits_from_cex(self, deposits: &[CexDeposit]) -> CexCredit<'_> { let cex = address_from_hex(DEV_ACCOUNT); // Reuse MockUSDT's deployed bytecode to give each token a working `balanceOf`. let runtime = self.anvil.code(&deploy_mock_erc20(&self.anvil, &cex)); // Every registered token is read in the shared batch, so a token without code would revert - // the whole scan even for holdings that do not involve it. + // the whole scan even for deposits that do not involve it. for token in self.supported_erc20_tokens() { self.anvil.set_code(&contract_address(token), &runtime); } let mut cex_totals: BTreeMap = BTreeMap::new(); - for holding in holdings { + for deposit in deposits { let total = cex_totals - .entry(contract_address(holding.token)) + .entry(contract_address(&deposit.token)) .or_default(); *total = total - .checked_add(holding.amount) + .checked_add(deposit.amount) .expect("BUG: the CEX account's balance overflows"); } for (token, total) in &cex_totals { @@ -314,25 +319,20 @@ impl LiveSetup { .set_storage_at(token, &erc20_balance_slot(&cex), &u256_be(*total)); } - let transfers: Vec<(Address, Address, u128)> = holdings + let transfers: Vec<(Address, Address, u128)> = deposits .iter() - .map(|holding| { + .map(|deposit| { ( - contract_address(holding.token), - holding.deposit, - holding.amount, + contract_address(&deposit.token), + deposit.address, + deposit.amount, ) }) .collect(); self.anvil.fund_in_one_block(&cex, &transfers); - - for holding in holdings { - assert_eq!( - self.anvil - .erc20_balance(&contract_address(holding.token), &holding.deposit), - Erc20Value::from(holding.amount), - "the deposit balance should be readable on anvil" - ); + CexCredit { + setup: self, + deposits, } } @@ -359,39 +359,71 @@ impl LiveSetup { token: &Erc20Token, ) -> DepositErc20Response { let funded_by = Nat::from(self.anvil.block_number()); - let mut scanned = None; + self.await_deposit_status( + caller, + subaccount, + token, + &format!("the deposit address was not scanned at or past block {funded_by}"), + |status| match status { + DepositStatus::Scanning { + scan_count, + last_scanned_block, + .. + } => { + *scan_count >= 1 + && last_scanned_block + .as_ref() + .is_some_and(|block| *block >= funded_by) + } + DepositStatus::AwaitingSweep(_) => true, + }, + ) + } + + pub fn await_detection( + &self, + caller: Principal, + subaccount: [u8; 32], + token: &Erc20Token, + ) -> DepositErc20Response { + self.await_deposit_status( + caller, + subaccount, + token, + "the deposit was not detected", + |status| matches!(status, DepositStatus::AwaitingSweep(_)), + ) + } + + fn await_deposit_status( + &self, + caller: Principal, + subaccount: [u8; 32], + token: &Erc20Token, + what: &str, + is_done: impl Fn(&DepositStatus) -> bool, + ) -> DepositErc20Response { + let mut reached = None; self.drive_until_with( SCAN_TICK, SCAN_TICKS, - |_| format!("the deposit address was not scanned at or past block {funded_by}"), + |_| what.to_string(), |setup| { let progress = setup.deposit_erc20(caller, subaccount, token); - let is_scanned = match &progress.status { - DepositStatus::Scanning { - scan_count, - last_scanned_block, - .. - } => { - *scan_count >= 1 - && last_scanned_block - .as_ref() - .is_some_and(|block| *block >= funded_by) - } - DepositStatus::AwaitingSweep(_) => true, - }; - if is_scanned { - scanned = Some(progress); + let done = is_done(&progress.status); + if done { + reached = Some(progress); } - is_scanned + done }, ); - scanned.expect("drive_until_with returns only once observe held") + reached.expect("drive_until_with returns only once observe held") } /// Waits for the sweeper address to send exactly `expected` transactions, returning what each /// did. An extra sweep is caught rather than ignored: sending more than `expected` fails /// immediately. - pub fn await_sweeps(&self, sweeper: &Address, expected: u64) -> Vec { + pub fn await_sweeps(self, sweeper: &Address, expected: u64) -> SweepsSent { self.drive_until( SWEEP_TICKS, |setup| { @@ -410,13 +442,17 @@ impl LiveSetup { sent == expected }, ); - self.anvil.transactions_of(sweeper) + let sweeps = self.anvil.transactions_of(sweeper); + SweepsSent { + setup: self, + sweeps, + } } /// Waits until `account` holds exactly `expected` on `ledger_id`. The mint follows the sweep's /// own finalized helper event through the minter's unchanged deposit pipeline, so this is what /// proves the whole chain ran. - pub fn await_credited(&self, ledger_id: Principal, account: Account, expected: u128) { + fn await_credited(&self, ledger_id: Principal, account: Account, expected: u128) { let credited = Nat::from(expected); self.drive_until( CREDIT_TICKS, @@ -431,6 +467,169 @@ impl LiveSetup { ); } + pub fn call_minter_deposit_erc20( + self, + plans: impl IntoIterator, + ) -> DepositErc20Calls { + let responses = plans + .into_iter() + .map(|plan| { + let response = self.deposit_erc20(plan.owner, plan.subaccount, &plan.token); + (plan, response) + }) + .collect(); + DepositErc20Calls { + setup: self, + responses, + } + } + + pub fn assert_deposit_addresses_bare(self, deposits: &[CexDeposit]) -> Self { + for deposit in deposits { + assert!( + self.anvil.code(&deposit.address).is_empty(), + "a deposit address starts with no code" + ); + assert_eq!( + self.anvil.balance(&deposit.address), + 0, + "a deposit address never needs ETH of its own" + ); + } + self + } + + pub fn assert_sweeps_batched_per_token(self, deposits: &[CexDeposit]) -> Self { + let mut batched: Vec<(Address, usize)> = self + .minter_events() + .into_iter() + .filter_map(|event| match event.payload { + EventPayload::AcceptedSweepRequest { token, items, .. } => Some(( + Address::from_str(&token).expect("BUG: the sweep names an invalid token"), + items.len(), + )), + _ => None, + }) + .collect(); + batched.sort(); + let mut expected_batches: BTreeMap = BTreeMap::new(); + for deposit in deposits { + *expected_batches + .entry(contract_address(&deposit.token)) + .or_default() += 1; + } + let expected: Vec<(Address, usize)> = expected_batches.into_iter().collect(); + assert_eq!( + batched, expected, + "each sweep must batch one token's deposits, not a mixed batch and a redundant one" + ); + self + } + + pub fn assert_addresses_swept_empty(self, deposits: &[CexDeposit]) -> Self { + for deposit in deposits { + assert_eq!( + self.anvil + .erc20_balance(&contract_address(&deposit.token), &deposit.address), + Erc20Value::from(0_u8), + "the deposit address should have been swept empty" + ); + } + self + } + + pub fn assert_minter_holds_swept_totals(self, deposits: &[CexDeposit]) -> Self { + let mut totals: BTreeMap = BTreeMap::new(); + for deposit in deposits { + *totals.entry(contract_address(&deposit.token)).or_default() += deposit.amount; + } + for (token, amount) in totals { + assert_eq!( + self.anvil.erc20_balance(&token, &self.minter_address), + Erc20Value::from(amount), + "the minter's main address should hold everything swept of {token}" + ); + } + self + } + + pub fn assert_delegations_installed(self, deposits: &[CexDeposit], delegate: &Address) -> Self { + let designator = delegation_designator(delegate); + for deposit in deposits { + assert_eq!( + self.anvil.code(&deposit.address), + designator, + "the sweep should have installed the delegation" + ); + } + self + } + + pub fn assert_sweeper_spent_gas(self, sweeper: &Address, funded_gas: u128) -> Self { + assert!( + self.anvil.balance(sweeper) < funded_gas, + "the sweeper address pays for the sweeps out of its own prepaid gas" + ); + self + } + + pub fn expect_mints(self, deposits: &[CexDeposit]) -> Self { + let mut ledgers: BTreeMap = BTreeMap::new(); + for deposit in deposits { + ledgers + .entry(contract_address(&deposit.token)) + .or_insert_with(|| { + self.ckerc20_token(&deposit.token.ledger_init_arg.token_symbol) + .ledger_canister_id + }); + } + let mut credits: BTreeMap<(Principal, Principal, Option<[u8; 32]>), u128> = BTreeMap::new(); + for deposit in deposits { + let ledger_id = ledgers[&contract_address(&deposit.token)]; + *credits + .entry((ledger_id, deposit.owner, Some(deposit.subaccount))) + .or_default() += deposit.amount; + } + for ((ledger_id, owner, subaccount), amount) in credits { + self.await_credited(ledger_id, Account { owner, subaccount }, amount); + } + self + } + + /// Waits until the minter has finalized `expected` sweeps successfully. A swept pair only leaves + /// the sweep queue on finalization, which runs one timer apart from the log scrape that mints, + /// so a test re-registering the pair waits for this rather than for its credit: registering + /// a pair still queued reports its stale detection instead of arming it afresh. + pub fn expect_sweeps_finalized(self, expected: usize) -> Self { + self.drive_until( + SWEEP_TICKS, + |setup| { + format!( + "the minter finalized {} of {expected} sweeps successfully (stages: {})", + setup.finalized_sweeps(), + setup.sweep_stages(), + ) + }, + |setup| setup.finalized_sweeps() == expected, + ); + self + } + + fn finalized_sweeps(&self) -> usize { + self.minter_events() + .iter() + .filter(|event| { + matches!( + &event.payload, + EventPayload::FinalizedSweeperTransaction { + transaction_receipt, + .. + } if transaction_receipt.status == TransactionStatus::Success + ) + }) + .count() + } + /// How far the sweep pipeline has got, counted off the minter's audit events. Unlike its /// canister log, which is a rolling buffer the EVM RPC canister's tracing evicts within /// minutes, the event log is durable — so this says which stage stalled even late in a run. @@ -455,23 +654,26 @@ impl LiveSetup { } } +impl Default for LiveSetup { + fn default() -> Self { + Self::new() + } +} + impl LiveSetup { - /// The ckETH fixture alone — funding touches no ERC-20 — with the fee account already holding - /// the ckETH a funding burns, its deposit also being the deposit-backed ETH the funding spends. + /// The ckETH fixture alone — funding touches no ERC-20. /// /// The minter's timers are left un-armed: a funding check runs on the next upgrade, so a test /// takes its ledger baselines and then calls [`Self::upgrade_minter`] when it is ready for the /// minter to act. Arming here instead would let the first check burn within milliseconds, before /// a test could read the pre-burn numbers. - pub fn new_funding() -> Self { + pub fn new() -> Self { let anvil = Arc::new(Anvil::start_mainnet_like()); let cketh = CkEthSetup::new(EthereumBackend::Anvil { anvil: Arc::clone(&anvil), sweep_contracts: None, }); - let setup = Self::go_live(cketh, anvil).with_deposit_helper(); - setup.fund_fee_account(); - setup + Self::go_live(cketh, anvil).with_deposit_helper() } /// Deploys the production deposit helper (`DepositHelperWithSubaccount.sol`) against the address @@ -524,6 +726,10 @@ impl> LiveSetup { self.cketh().get_all_events() } + pub fn get_minter_info(&self) -> MinterInfo { + self.cketh().get_minter_info() + } + /// Polls until `observe` produces a value, or fails with what the minter was doing. The shape /// every wait here had spelled out for itself; the sleep is [`POLL_INTERVAL`], as for the ticks. fn poll_until( @@ -586,8 +792,9 @@ impl> LiveSetup { /// Re-arms the minter's periodic timers by upgrading it, so its checks run again inside the test /// rather than at the next scheduled tick. - pub fn upgrade_minter(&self) { + pub fn upgrade_minter(self) -> MinterUpgraded { self.upgrade_minter_with(UpgradeArg::default()); + MinterUpgraded { setup: self } } /// As [`Self::upgrade_minter`], carrying a configuration change. @@ -660,7 +867,7 @@ impl> LiveSetup { /// /// `what` is rendered only on failure, so it can read state that is worth knowing at that point /// and would say nothing at the start. - pub fn drive_until( + fn drive_until( &self, max_ticks: u32, what: impl Fn(&Self) -> String, @@ -690,13 +897,21 @@ impl> LiveSetup { } } - pub fn fee_account(&self) -> Account { + fn fee_account(&self) -> Account { Account { owner: self.minter_id(), subaccount: Some(ic_cketh_minter::CKETH_FEE_SUBACCOUNT), } } + pub fn funding_baseline(&self) -> FundingBaseline { + FundingBaseline { + cketh_total_supply: self.cketh_total_supply(), + fee_account_balance: self.cketh_balance_of(self.fee_account()), + minter_eth_balance: self.anvil_eth_balance(&self.minter_address), + } + } + fn await_funding_finalized(&self) { self.drive_until( FUNDING_TICKS, @@ -715,13 +930,13 @@ impl> LiveSetup { ); } - fn fund_fee_account(&self) { + pub fn fund_fee_account(self) -> FeeAccountFunding { // The fee account earns its ckETH the way it does in production — the ckETH ledger collects // its fees there — but at 2e12 wei a transfer it would take 150'000 transfers to reach the // funding target, so the harness deposits to that account directly instead. Deposited rather // than minted so nothing here mints ckETH the minter did not back with ETH. self.deposit(self.fee_account(), FEE_ACCOUNT_BALANCE); - self.await_deposits_credited(&[self.fee_account()]); + FeeAccountFunding { setup: self } } /// Deposits `value` wei for `beneficiary` through the helper contract, as a depositor does. @@ -738,13 +953,10 @@ impl> LiveSetup { ); } - /// The sweeper address the minter derived, scraped from its log line: there is no getter for it - /// yet, and it cannot be derived test-side without the master public key. fn sweeper_address(&self) -> Option
{ - self.minter_logs().iter().find_map(|line| { - let rest = line.split("[fund_sweeper]: ").nth(1)?; - let hex = rest.split_whitespace().next()?; - hex.parse().ok() + self.get_minter_info().sweeper_address.map(|address| { + Address::from_str(&address) + .expect("BUG: the minter reported an invalid sweeper address") }) } @@ -758,15 +970,11 @@ impl> LiveSetup { ) } - pub fn minter_address(&self) -> Address { - self.minter_address - } - - pub fn cketh_balance_of(&self, account: Account) -> u128 { + fn cketh_balance_of(&self, account: Account) -> u128 { nat_to_u128(self.cketh().balance_of(account)) } - pub fn cketh_total_supply(&self) -> u128 { + fn cketh_total_supply(&self) -> u128 { let reply = self .env() .query_call( @@ -784,7 +992,7 @@ impl> LiveSetup { /// /// Budget at least two ticks: the funding task's burn is on its own timer, so the first tick can /// land before there is any request to send. - pub fn await_eth_received(&self, recipient: &Address, max_ticks: u32) -> u128 { + fn await_eth_received(&self, recipient: &Address, max_ticks: u32) -> u128 { self.drive_until( max_ticks, |setup| { @@ -813,6 +1021,220 @@ impl> LiveSetup { } } +#[must_use] +pub struct FeeAccountFunding { + setup: LiveSetup, +} + +impl> FeeAccountFunding { + pub fn expect_fee_account_credited(self) -> LiveSetup { + self.setup + .await_deposits_credited(&[self.setup.fee_account()]); + self.setup + } +} + +#[must_use] +pub struct MinterUpgraded { + pub setup: LiveSetup, +} + +impl> MinterUpgraded { + pub fn expect_sweeper_address_derived(self) -> SweeperFunding { + let sweeper = self.setup.await_sweeper_address(); + SweeperFunding { + setup: self.setup, + sweeper, + } + } + + pub fn expect_sweeper_address(self, expected: &Address) -> SweeperFunding { + let funding = self.expect_sweeper_address_derived(); + assert_eq!( + funding.sweeper, *expected, + "the minter derived a sweeper address other than the expected one" + ); + funding + } +} + +#[must_use] +pub struct SweeperFunding { + setup: LiveSetup, + sweeper: Address, +} + +impl> SweeperFunding { + pub fn expect_sweeper_starts_empty(self) -> Self { + assert_eq!( + self.setup.anvil_eth_balance(&self.sweeper), + 0, + "the sweeper address must start empty, so any balance proves the funding landed" + ); + self + } + + pub fn expect_eth_received(self) -> SweeperFunded { + let received = self.setup.await_eth_received(&self.sweeper, FUNDING_TICKS); + SweeperFunded { + setup: self.setup, + received, + } + } +} + +#[must_use] +pub struct SweeperFunded { + setup: LiveSetup, + received: u128, +} + +impl> SweeperFunded { + pub fn expect_funding_finalized(self) -> LiveSetup { + self.setup.await_funding_finalized(); + self.setup + } + + pub fn expect_funding_backed_by_burn(self, baseline: &FundingBaseline) -> LiveSetup { + let burned = baseline + .cketh_total_supply + .checked_sub(self.setup.cketh_total_supply()) + .expect("the funding must have burned ckETH, not minted it"); + assert!(burned > 0, "funding must burn ckETH"); + assert_eq!( + baseline.fee_account_balance - self.setup.cketh_balance_of(self.setup.fee_account()), + burned, + "the burn must be debited from the fee account" + ); + // The ETH moved, and never more than was burned — the backing invariant, observed end to + // end. + let spent = + baseline.minter_eth_balance - self.setup.anvil_eth_balance(&self.setup.minter_address); + let received = self.received; + assert!( + received > 0 && received < burned, + "the sweeper receives the burned amount minus the fee, got received={received} burned={burned}" + ); + assert!( + spent <= burned, + "the ETH debited from the main address ({spent}) must never exceed the ckETH \ + burned for it ({burned})" + ); + self.setup + } +} + +pub struct FundingBaseline { + cketh_total_supply: u128, + fee_account_balance: u128, + minter_eth_balance: u128, +} + +#[must_use] +pub struct DepositErc20Calls { + setup: LiveSetup, + responses: Vec<(DepositPlan, DepositErc20Response)>, +} + +impl DepositErc20Calls { + pub fn expect_deposit_responses(self) -> (LiveSetup, Vec) { + let deposits: Vec = self + .responses + .into_iter() + .map(|(plan, response)| CexDeposit { + address: Address::from_str(&response.address) + .expect("BUG: minter returned an invalid deposit address"), + owner: plan.owner, + subaccount: plan.subaccount, + token: plan.token, + amount: plan.amount, + }) + .collect(); + let accounts: BTreeSet<(Principal, [u8; 32])> = deposits + .iter() + .map(|deposit| (deposit.owner, deposit.subaccount)) + .collect(); + let addresses: BTreeSet
= deposits.iter().map(|deposit| deposit.address).collect(); + assert_eq!( + addresses.len(), + accounts.len(), + "every account must get its own deposit address" + ); + (self.setup, deposits) + } +} + +#[must_use] +pub struct CexCredit<'a> { + setup: LiveSetup, + deposits: &'a [CexDeposit], +} + +impl<'a> CexCredit<'a> { + pub fn expect_deposit_balances_on_anvil(self) -> DetectionWatch<'a> { + for deposit in self.deposits { + assert_eq!( + self.setup + .anvil + .erc20_balance(&contract_address(&deposit.token), &deposit.address), + Erc20Value::from(deposit.amount), + "the deposit balance should be readable on anvil" + ); + } + DetectionWatch { + setup: self.setup, + deposits: self.deposits, + } + } +} + +#[must_use] +pub struct DetectionWatch<'a> { + pub setup: LiveSetup, + deposits: &'a [CexDeposit], +} + +impl DetectionWatch<'_> { + pub fn expect_each_awaiting_sweep(self) -> LiveSetup { + for deposit in self.deposits { + let detected = match self + .setup + .await_detection(deposit.owner, deposit.subaccount, &deposit.token) + .status + { + DepositStatus::AwaitingSweep(detected) => detected, + status => panic!("BUG: await_detection returned {status:?}"), + }; + assert_eq!( + detected.scanned_balance, + Nat::from(deposit.amount), + "the detected balance must match the deposited amount" + ); + } + self.setup + } +} + +#[must_use] +pub struct SweepsSent { + setup: LiveSetup, + sweeps: Vec, +} + +impl SweepsSent { + pub fn expect_all_delegating_sweeps(self) -> (LiveSetup, Vec) { + for sweep in &self.sweeps { + assert_eq!( + sweep.transaction_type, 4, + "a sweep here always carries its EIP-7702 authorizations, installed or re-sent, \ + so it must be a type-4 transaction: {sweep:?}" + ); + assert!(sweep.succeeded, "the sweep reverted: {sweep:?}"); + } + (self.setup, self.sweeps) + } +} + /// The minter's Ethereum address, awaited without ticking (see the module documentation). fn fetch_minter_address(cketh: &CkEthSetup) -> Address { let message_id = cketh