diff --git a/rs/ethereum/cketh/minter/src/lifecycle/init.rs b/rs/ethereum/cketh/minter/src/lifecycle/init.rs index c10cee1a6961..66e8527f4e5a 100644 --- a/rs/ethereum/cketh/minter/src/lifecycle/init.rs +++ b/rs/ethereum/cketh/minter/src/lifecycle/init.rs @@ -120,6 +120,7 @@ impl TryFrom for State { log_scrapings, automatic_deposits: AutomaticDeposits::default(), sweeper_contract_address, + sweeper_funding: Default::default(), }; state.validate_config()?; Ok(state) diff --git a/rs/ethereum/cketh/minter/src/state.rs b/rs/ethereum/cketh/minter/src/state.rs index 8780abf511f4..48ae8762b47f 100644 --- a/rs/ethereum/cketh/minter/src/state.rs +++ b/rs/ethereum/cketh/minter/src/state.rs @@ -13,6 +13,7 @@ use crate::numeric::{ }; use crate::state::automatic_deposits::{AutomaticDeposits, ScanProgress}; use crate::state::eth_logs_scraping::{LogScrapingId, LogScrapings}; +use crate::state::sweeper_funding::{SweeperFundingAccounting, SweeperFundingConfig}; use crate::state::transactions::{Erc20WithdrawalRequest, TransactionCallData, WithdrawalRequest}; use crate::timed_sized_map::{Entry, Timestamp}; use crate::tx::GasFeeEstimate; @@ -32,6 +33,7 @@ pub mod audit; pub mod automatic_deposits; pub mod eth_logs_scraping; pub mod event; +pub mod sweeper_funding; pub mod transactions; #[cfg(test)] @@ -120,6 +122,9 @@ pub struct State { /// Address of the sweeper smart contract on Ethereum, which the minter /// delegates to when sweeping funded deposit addresses. pub sweeper_contract_address: Option
, + + /// Burn-first accounting for sweeper fee funding. + pub sweeper_funding: SweeperFundingAccounting, } #[derive(Eq, PartialEq, Debug)] @@ -181,6 +186,16 @@ impl State { EthereumNetwork::Mainnet => Wei::new(2_000_000_000_000), EthereumNetwork::Sepolia => Wei::new(10_000_000_000), }; + if SweeperFundingConfig::for_minimum_withdrawal_amount(self.cketh_minimum_withdrawal_amount) + .is_none() + { + return Err(InvalidStateError::InvalidMinimumWithdrawalAmount(format!( + "minimum_withdrawal_amount {} is too large: the sweeper funding target is {} \ + times it, which does not fit", + self.cketh_minimum_withdrawal_amount, + crate::state::sweeper_funding::SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS, + ))); + } if self.cketh_minimum_withdrawal_amount < cketh_ledger_transfer_fee { return Err(InvalidStateError::InvalidMinimumWithdrawalAmount( "minimum_withdrawal_amount must cover ledger transaction fee, \ @@ -429,6 +444,15 @@ impl State { self.eth_balance.total_effective_tx_fees_add(tx_fee); self.eth_balance.total_unspent_tx_fees_add(unspent_tx_fee); + if matches!(withdrawal_request, WithdrawalRequest::SweeperFunding(_)) { + let transferred = match receipt.status { + TransactionStatus::Success => tx.transaction().amount, + TransactionStatus::Failure => Wei::ZERO, + }; + self.sweeper_funding + .record_finalized_funding(transferred, tx_fee); + } + if receipt.status == TransactionStatus::Success && !tx.transaction_data().is_empty() { let TransactionCallData::Erc20Transfer { to: _, value } = TransactionCallData::decode( tx.transaction_data(), @@ -595,6 +619,13 @@ impl State { self.validate_config() } + /// When to top the sweeper address up, and to what: derived from the minimum withdrawal + /// amount, which [`Self::validate_config`] keeps small enough for the derivation to fit. + pub fn sweeper_funding_config(&self) -> SweeperFundingConfig { + SweeperFundingConfig::for_minimum_withdrawal_amount(self.cketh_minimum_withdrawal_amount) + .expect("BUG: validate_config rejects a minimum withdrawal amount this large") + } + /// Checks whether two states are equivalent. pub fn is_equivalent_to(&self, other: &Self) -> Result<(), String> { // We define the equivalence using the upgrade procedure. @@ -632,6 +663,7 @@ impl State { self.sweeper_contract_address, other.sweeper_contract_address ); + ensure_eq!(self.sweeper_funding, other.sweeper_funding); self.withdrawal_transactions .is_equivalent_to(&other.withdrawal_transactions) diff --git a/rs/ethereum/cketh/minter/src/state/audit.rs b/rs/ethereum/cketh/minter/src/state/audit.rs index 685f9dda17c3..93502baeff1d 100644 --- a/rs/ethereum/cketh/minter/src/state/audit.rs +++ b/rs/ethereum/cketh/minter/src/state/audit.rs @@ -75,6 +75,7 @@ pub fn apply_state_transition(state: &mut State, payload: &EventType) { .record_withdrawal_request(request.clone()); } EventType::AcceptedSweeperFundingRequest(request) => { + state.sweeper_funding.record_burn(request.withdrawal_amount); // Named explicitly: the payload converts to `CkEth` on its own, which would make the // funding reimbursable. state diff --git a/rs/ethereum/cketh/minter/src/state/sweeper_funding.rs b/rs/ethereum/cketh/minter/src/state/sweeper_funding.rs new file mode 100644 index 000000000000..649e52458025 --- /dev/null +++ b/rs/ethereum/cketh/minter/src/state/sweeper_funding.rs @@ -0,0 +1,132 @@ +//! Burn-first accounting for sweeper fee funding, "Fund the transaction fees without touching the +//! ckETH backing": ckETH is burned from the minter's fee subaccount *before* the ETH moves, so that +//! at every instant +//! +//! ```text +//! cumulative ckETH burned for sweeping >= cumulative ETH debited from the main address for sweeping +//! ``` +//! +//! The surplus is never re-minted and never discounted from a later burn. It sits at the *main* +//! address, not the sweeper's, so it is tracked here rather than read back on chain. + +#[cfg(test)] +mod tests; + +use crate::numeric::Wei; + +/// How much ckETH has been burned for sweeping and how much of it has actually been spent: the two +/// sides of the invariant above. +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct SweeperFundingAccounting { + /// Grows when a funding is accepted, by the whole amount that funding may spend. + cumulative_burned: Wei, + /// Grows when that funding's transaction finalizes, by the ETH the sweeper received — nothing + /// if it failed. + cumulative_transferred: Wei, + /// Grows at the same point by the fee the transaction paid, which it does either way. Together + /// with the amount transferred it is the spend, which never overtakes the burn. + cumulative_transaction_fees: Wei, +} + +impl Default for SweeperFundingAccounting { + fn default() -> Self { + Self { + cumulative_burned: Wei::ZERO, + cumulative_transferred: Wei::ZERO, + cumulative_transaction_fees: Wei::ZERO, + } + } +} + +impl SweeperFundingAccounting { + /// Records a burn from the fee subaccount, before any ETH moves. + pub fn record_burn(&mut self, amount: Wei) { + self.cumulative_burned = self + .cumulative_burned + .checked_add(amount) + .expect("BUG: overflow in cumulative burned for sweeping"); + } + + /// Records a finalized funding transaction: `transferred` reached the sweeper address (zero if + /// the transaction failed) and `transaction_fee` was spent on gas either way. + pub fn record_finalized_funding(&mut self, transferred: Wei, transaction_fee: Wei) { + self.cumulative_transferred = self + .cumulative_transferred + .checked_add(transferred) + .expect("BUG: overflow in cumulative transferred to the sweeper"); + self.cumulative_transaction_fees = self + .cumulative_transaction_fees + .checked_add(transaction_fee) + .expect("BUG: overflow in cumulative sweeper funding fees"); + // Checked eagerly so a violation surfaces at the transition that caused it. + let _ = self.burned_not_yet_spent(); + } + + /// Total ETH debited from the main address on account of sweeping. + pub fn cumulative_spent(&self) -> Wei { + self.cumulative_transferred + .checked_add(self.cumulative_transaction_fees) + .expect("BUG: overflow in cumulative spent on sweeping") + } + + pub fn cumulative_burned(&self) -> Wei { + self.cumulative_burned + } + + /// ckETH burned for sweeping that has not been spent yet: the burn of a funding in flight, plus + /// the fees earlier fundings provisioned but did not pay. Panics rather than saturating if spend + /// ever exceeds burn, which would mean ckETH is under-backed. + pub fn burned_not_yet_spent(&self) -> Wei { + self.cumulative_burned + .checked_sub(self.cumulative_spent()) + .expect( + "BUG: more ETH spent on sweeping than ckETH burned for it, \ + meaning ckETH is under-backed", + ) + } +} + +/// When to top the sweeper address up, and to what. Derived from the minimum withdrawal amount, so +/// that the gap between the two — the smallest amount a funding moves — clears the ledger minimum by +/// construction. +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub struct SweeperFundingConfig { + /// Fund the sweeper address once its ETH balance falls below this. + pub low_water_mark: Wei, + /// Top the sweeper address up to this balance. + pub target: Wei, +} + +/// The target in minimum withdrawal amounts: 0.3 ETH against mainnet's 0.03. Provisional, to be +/// calibrated during the Sepolia rollout. +pub const SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS: u8 = 10; + +impl SweeperFundingConfig { + /// Refilling starts at half the target, so a funding moves at least five minimum withdrawal + /// amounts. `None` if the target would overflow, which [`State::validate_config`] rejects. + /// + /// [`State::validate_config`]: crate::state::State::validate_config + pub fn for_minimum_withdrawal_amount(minimum_withdrawal_amount: Wei) -> Option { + let target = minimum_withdrawal_amount + .checked_mul(SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS)?; + Some(Self { + low_water_mark: target + .checked_div_floor(2_u8) + .expect("BUG: dividing by a non-zero constant"), + target, + }) + } + + /// How much ETH to move to bring `sweeper_balance` up to the target, or `None` when the + /// balance is still above the low-water mark and no funding is due. + pub fn amount_due(&self, sweeper_balance: Wei) -> Option { + if sweeper_balance >= self.low_water_mark { + return None; + } + Some( + self.target + .checked_sub(sweeper_balance) + .expect("BUG: the low-water mark must not exceed the target"), + ) + } +} diff --git a/rs/ethereum/cketh/minter/src/state/sweeper_funding/tests.rs b/rs/ethereum/cketh/minter/src/state/sweeper_funding/tests.rs new file mode 100644 index 000000000000..e5cf4cc2d9ac --- /dev/null +++ b/rs/ethereum/cketh/minter/src/state/sweeper_funding/tests.rs @@ -0,0 +1,165 @@ +use crate::numeric::Wei; +use crate::state::sweeper_funding::{SweeperFundingAccounting, SweeperFundingConfig}; + +const BURN: u128 = 100_000_000_000_000_000; // 0.1 ETH +const FEE: u128 = 1_000_000_000_000_000; // 0.001 ETH + +mod accounting { + use super::*; + + #[test] + fn should_start_empty() { + let accounting = SweeperFundingAccounting::default(); + + assert_eq!(accounting.cumulative_burned(), Wei::ZERO); + assert_eq!(accounting.cumulative_spent(), Wei::ZERO); + assert_eq!(accounting.burned_not_yet_spent(), Wei::ZERO); + } + + #[test] + fn should_leave_no_surplus_after_a_successful_funding() { + let mut accounting = SweeperFundingAccounting::default(); + accounting.record_burn(Wei::new(BURN)); + accounting.record_finalized_funding(Wei::new(BURN - FEE), Wei::new(FEE)); + + assert_eq!(accounting.cumulative_spent(), Wei::new(BURN)); + assert_eq!( + accounting.burned_not_yet_spent(), + Wei::ZERO, + "burn and spend must balance exactly on success" + ); + } + + #[test] + fn should_keep_the_unspent_fee_as_surplus_after_a_successful_funding() { + let mut accounting = SweeperFundingAccounting::default(); + accounting.record_burn(Wei::new(BURN)); + accounting.record_finalized_funding(Wei::new(BURN - FEE), Wei::new(FEE / 2)); + + assert_eq!( + accounting.burned_not_yet_spent(), + Wei::new(FEE / 2), + "the fee provisioned but never paid stays as backing" + ); + } + + #[test] + fn should_keep_the_burn_as_surplus_after_a_failed_funding() { + let mut accounting = SweeperFundingAccounting::default(); + accounting.record_burn(Wei::new(BURN)); + accounting.record_finalized_funding(Wei::ZERO, Wei::new(FEE)); + + assert_eq!(accounting.cumulative_spent(), Wei::new(FEE)); + assert_eq!( + accounting.burned_not_yet_spent(), + Wei::new(BURN - FEE), + "everything except the gas actually paid stays as backing" + ); + assert!( + accounting.cumulative_burned() > accounting.cumulative_spent(), + "burned must exceed spent, i.e. ckETH is over-backed rather than under-backed" + ); + } + + #[test] + fn should_accumulate_across_fundings() { + let mut accounting = SweeperFundingAccounting::default(); + for _ in 0..3 { + accounting.record_burn(Wei::new(BURN)); + accounting.record_finalized_funding(Wei::new(BURN - FEE), Wei::new(FEE)); + } + + assert_eq!(accounting.cumulative_burned(), Wei::new(3 * BURN)); + assert_eq!(accounting.cumulative_spent(), Wei::new(3 * BURN)); + assert_eq!(accounting.burned_not_yet_spent(), Wei::ZERO); + } + + #[test] + #[should_panic(expected = "more ETH spent on sweeping than ckETH burned")] + fn should_panic_when_spending_more_than_was_burned() { + let mut accounting = SweeperFundingAccounting::default(); + accounting.record_burn(Wei::new(FEE)); + + accounting.record_finalized_funding(Wei::new(BURN), Wei::new(FEE)); + } +} + +mod config { + use super::*; + use crate::state::sweeper_funding::SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS; + use proptest::prelude::*; + + const MINIMUM_BURN: u128 = 30_000_000_000_000_000; // ckETH's mainnet minimum withdrawal amount + + fn config_for(minimum_withdrawal_amount: u128) -> SweeperFundingConfig { + SweeperFundingConfig::for_minimum_withdrawal_amount(Wei::new(minimum_withdrawal_amount)) + .expect("test setup: the bounds must fit") + } + + #[test] + fn should_leave_headroom_above_the_minimum_withdrawal_amount() { + for minimum in [ + 1, + 1_000, + 10_000_000_000, // Sepolia's ledger transfer fee + MINIMUM_BURN, + 1_000 * MINIMUM_BURN, + ] { + let config = config_for(minimum); + let headroom = config + .target + .checked_sub(config.low_water_mark) + .expect("the target must exceed the low-water mark"); + + assert!( + headroom >= Wei::new(minimum), + "a funding of a minter with minimum {minimum} moves at least {headroom}, \ + which must cover the minimum itself" + ); + } + } + + #[test] + fn should_report_no_bounds_when_the_target_would_not_fit() { + let too_large = Wei::MAX + .checked_div_floor(SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS) + .unwrap() + .checked_add(Wei::ONE) + .unwrap(); + + assert_eq!( + SweeperFundingConfig::for_minimum_withdrawal_amount(too_large), + None, + "the caller must find out rather than the derivation trapping" + ); + } + + #[test] + fn should_not_fund_above_the_low_water_mark() { + let config = config_for(MINIMUM_BURN); + + assert_eq!(config.amount_due(config.target), None); + assert_eq!( + config.amount_due(config.low_water_mark), + None, + "at the mark, not below" + ); + } + + proptest! { + #[test] + fn should_fund_up_to_the_target( + balance in 0..MINIMUM_BURN * SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS as u128 + ) { + let config = config_for(MINIMUM_BURN); + let balance = Wei::new(balance); + prop_assume!(balance < config.low_water_mark); + + let amount_due = config + .amount_due(balance) + .expect("a balance below the low-water mark is due a funding"); + + prop_assert_eq!(balance.checked_add(amount_due), Some(config.target)); + } + } +} diff --git a/rs/ethereum/cketh/minter/src/state/tests.rs b/rs/ethereum/cketh/minter/src/state/tests.rs index 6a9f7157e78d..d57aaa0d94d6 100644 --- a/rs/ethereum/cketh/minter/src/state/tests.rs +++ b/rs/ethereum/cketh/minter/src/state/tests.rs @@ -298,6 +298,35 @@ mod upgrade { use num_bigint::BigUint; use std::str::FromStr; + #[test] + fn should_track_the_minimum_withdrawal_amount_in_the_funding_bounds() { + let mut state = initial_state(); + let before = state.sweeper_funding_config(); + + let raised = 90_000_000_000_000_000_u128; // 0.09 ETH, triple mainnet's minimum + state + .upgrade(UpgradeArg { + minimum_withdrawal_amount: Some(Nat::from(raised)), + ..Default::default() + }) + .expect("raising the minimum withdrawal amount must be accepted"); + + let after = state.sweeper_funding_config(); + assert!( + after.target > before.target && after.low_water_mark > before.low_water_mark, + "the bounds are derived from the minimum, so raising it must move both: \ + {before:?} -> {after:?}" + ); + assert!( + after + .target + .checked_sub(after.low_water_mark) + .expect("the target must exceed the low-water mark") + >= Wei::new(raised), + "and the smallest amount a funding moves must still cover the raised minimum" + ); + } + #[test] fn should_fail_when_upgrade_args_invalid() { let mut state = initial_state(); @@ -320,6 +349,17 @@ mod upgrade { Err(InvalidStateError::InvalidMinimumWithdrawalAmount(_)) ); + let mut state = initial_state(); + assert_matches!( + state.upgrade(UpgradeArg { + minimum_withdrawal_amount: Some(Nat(BigUint::from_bytes_be( + ðnum::u256::MAX.to_be_bytes(), + ))), + ..Default::default() + }), + Err(InvalidStateError::InvalidMinimumWithdrawalAmount(_)) + ); + let mut state = initial_state(); state.ethereum_network = EthereumNetwork::Mainnet; assert_matches!( @@ -1124,6 +1164,7 @@ fn state_equivalence() { deposits }; let state = State { + sweeper_funding: Default::default(), ethereum_network: EthereumNetwork::Mainnet, ecdsa_key_name: "test_key".to_string(), cketh_ledger_id: "apia6-jaaaa-aaaar-qabma-cai".parse().unwrap(), @@ -1493,6 +1534,7 @@ mod eth_balance { use crate::state::tests::{initial_state, received_eth_event}; use crate::state::transactions::{EthWithdrawalRequest, WithdrawalRequest, create_transaction}; use crate::state::{EthBalance, State}; + use crate::test_fixtures::sweeper_funding_request; use crate::tx::{SignedEip1559TransactionRequest, TransactionSignature}; use maplit::btreemap; @@ -1562,8 +1604,7 @@ mod eth_balance { assert_eq!(balance_after_erc20_deposit, balance_before); } - /// Both outcomes of one withdrawal flow, applied to independent copies of the same starting - /// state so a test can compare them directly. + /// Both outcomes of one flow, each applied to its own copy of the starting state. struct BothOutcomes { after_success: State, after_failure: State, @@ -1571,10 +1612,8 @@ mod eth_balance { receipt_failed: TransactionReceipt, } - /// Runs the shared 21'000-gas flow against `state_before` twice, once finalizing successfully and - /// once with a failure receipt, leaving each caller to assert only what its request type makes - /// different. The gas fixture comes from a real Sepolia transaction and over-provisions the fee, - /// so part of it is always recorded as unspent. + /// Runs the 21'000-gas flow twice, once finalizing successfully and once with a failure + /// receipt. The gas fixture over-provisions the fee, so part of it is always left unspent. fn apply_eth_transfer_both_ways>( state_before: &State, request: T, @@ -1609,6 +1648,96 @@ mod eth_balance { } } + #[test] + fn should_account_for_a_successful_sweeper_funding() { + let mut state = initial_state(); + apply_state_transition( + &mut state, + &EventType::AcceptedDeposit(received_eth_event()), + ); + let eth_balance_before = state.eth_balance.eth_balance(); + + let funding = sweeper_funding_request(Wei::new(10_000_000_000_000_000)); + let receipt = WithdrawalFlow { + tx_status: TransactionStatus::Success, + ..WithdrawalFlow::for_request(WithdrawalRequest::SweeperFunding(funding.clone())) + } + .apply(&mut state); + + assert_eq!( + state.sweeper_funding.cumulative_burned(), + funding.withdrawal_amount, + "the burn is recorded when the funding is accepted, before any ETH moves" + ); + let spent = state.sweeper_funding.cumulative_spent(); + let unspent_fee_allowance = state.eth_balance.total_unspent_tx_fees(); + assert!( + unspent_fee_allowance > Wei::ZERO, + "test setup: the effective fee must be below the charged max fee" + ); + assert_eq!( + spent, + funding + .withdrawal_amount + .checked_sub(unspent_fee_allowance) + .unwrap(), + "spend is the burn minus the fee allowance that was charged but not used" + ); + assert_eq!( + state.sweeper_funding.burned_not_yet_spent(), + unspent_fee_allowance, + "the unused fee allowance stays as backing, neither re-minted nor offset" + ); + assert_eq!( + state.eth_balance.eth_balance(), + eth_balance_before.checked_sub(spent).unwrap(), + "the ETH balance is debited by exactly what was spent" + ); + assert!( + state.sweeper_funding.cumulative_burned() >= spent, + "burned must never fall below spent" + ); + assert_eq!(receipt.status, TransactionStatus::Success); + } + + #[test] + fn should_keep_a_failed_sweeper_funding_as_backing() { + let mut state = initial_state(); + apply_state_transition( + &mut state, + &EventType::AcceptedDeposit(received_eth_event()), + ); + let eth_balance_before = state.eth_balance.eth_balance(); + + let funding = sweeper_funding_request(Wei::new(10_000_000_000_000_000)); + let receipt = WithdrawalFlow { + tx_status: TransactionStatus::Failure, + ..WithdrawalFlow::for_request(WithdrawalRequest::SweeperFunding(funding.clone())) + } + .apply(&mut state); + + let spent = state.sweeper_funding.cumulative_spent(); + assert_eq!( + state.sweeper_funding.cumulative_burned(), + funding.withdrawal_amount + ); + assert_eq!( + spent, + receipt.effective_transaction_fee(), + "a failed funding moved no ETH, so the gas it paid is the whole spend" + ); + assert_eq!( + state.sweeper_funding.burned_not_yet_spent(), + funding.withdrawal_amount.checked_sub(spent).unwrap(), + "the rest stays as backing, available to no later funding" + ); + assert_eq!( + state.eth_balance.eth_balance(), + eth_balance_before.checked_sub(spent).unwrap(), + "only the fee left the main address, so ckETH is over-backed, never under-backed" + ); + } + #[test] fn should_update_after_successful_and_failed_withdrawal() { let mut state_before_withdrawal = initial_state(); @@ -1683,11 +1812,6 @@ mod eth_balance { ); } - /// Funding takes its own arm in the balance accounting, so the ckETH and ckERC20 cases above - /// cannot reach it. What is asserted is the same shape as a ckETH withdrawal — a success debits - /// the transferred ETH plus the fee actually paid, a failure debits only that fee — because - /// funding is an ordinary withdrawal to the accounting. What differs is that nothing is ever - /// reimbursed, which is why the failing case must still leave the fee counters moving. #[test] fn should_update_after_successful_and_failed_sweeper_funding() { let mut state_before_funding = initial_state(); @@ -1714,9 +1838,7 @@ mod eth_balance { let receipt_succeeded = &outcomes.receipt_succeeded; let after_success = outcomes.after_success.eth_balance.clone(); - // Asserted as the identity the accounting has to satisfy rather than as a fixed number: the - // funding ceiling covers both the ETH delivered and the fee, so whatever part of the fee - // went unspent is exactly what stays with the minter. + // An identity rather than a fixed number: the ceiling covers the ETH delivered plus the fee. let unspent = after_success .total_unspent_tx_fees .checked_sub(eth_balance_before_funding.total_unspent_tx_fees) diff --git a/rs/ethereum/cketh/minter/src/test_fixtures.rs b/rs/ethereum/cketh/minter/src/test_fixtures.rs index 4a47f86790f1..9acdbc5cee4d 100644 --- a/rs/ethereum/cketh/minter/src/test_fixtures.rs +++ b/rs/ethereum/cketh/minter/src/test_fixtures.rs @@ -1,6 +1,9 @@ use crate::EVM_RPC_ID_STAGING; +use crate::eth_logs::LedgerSubaccount; use crate::lifecycle::init::InitArg; +use crate::numeric::{LedgerBurnIndex, Wei}; use crate::state::State; +use crate::state::transactions::EthWithdrawalRequest; use candid::{Nat, Principal}; pub fn expect_panic_with_message R, R: std::fmt::Debug>( @@ -30,6 +33,23 @@ pub fn initial_state() -> State { State::try_from(valid_init_arg()).expect("BUG: invalid init arg") } +/// A sweeper funding request of `withdrawal_amount`, as the funding task builds one: burned from the +/// minter's own fee subaccount, sent to its sweeper address. +pub fn sweeper_funding_request(withdrawal_amount: Wei) -> EthWithdrawalRequest { + EthWithdrawalRequest { + withdrawal_amount, + destination: "0x5353535353535353535353535353535353535353" + .parse() + .unwrap(), + ledger_burn_index: LedgerBurnIndex::new(0), + from: "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae" + .parse() + .unwrap(), + from_subaccount: LedgerSubaccount::from_bytes(crate::CKETH_FEE_SUBACCOUNT), + created_at: Some(1699527697000000000), + } +} + /// Install `state` into the global thread-local `STATE`, so `read_state`/`mutate_state` see it in a /// unit test. Each test runs on its own thread, so the `thread_local!` `STATE` is per-test. pub fn init_state(state: State) {