From 305a8a77959463feb174c106b0e924c58603c98c Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Fri, 14 Aug 2026 13:42:12 +0200 Subject: [PATCH 1/3] feat(cketh): configure the sweeper contract address Add an optional `ethereum_sweeper_contract_address` field to the ckETH minter's `InitArg` and `UpgradeArg`, holding the address of the sweeper smart contract deployed on Ethereum. The address is parsed into the minter's state at install time and can be changed on upgrade, mirroring how `ethereum_contract_address` is handled. Nothing consumes it yet. Co-Authored-By: Claude Opus 5 (1M context) --- rs/ethereum/cketh/minter/cketh_minter.did | 6 +++ .../cketh/minter/src/dashboard/tests.rs | 1 + .../cketh/minter/src/lifecycle/init.rs | 10 ++++ .../cketh/minter/src/lifecycle/tests.rs | 31 +++++++++++- .../cketh/minter/src/lifecycle/upgrade.rs | 2 + rs/ethereum/cketh/minter/src/state.rs | 17 +++++++ rs/ethereum/cketh/minter/src/state/tests.rs | 47 ++++++++++++++++++- rs/ethereum/cketh/minter/src/test_fixtures.rs | 1 + rs/ethereum/cketh/test_utils/src/lib.rs | 1 + rs/ethereum/cketh/test_utils/src/live_scan.rs | 1 + rs/tests/cross_chain/ic_xc_cketh_test.rs | 1 + 11 files changed, 116 insertions(+), 2 deletions(-) diff --git a/rs/ethereum/cketh/minter/cketh_minter.did b/rs/ethereum/cketh/minter/cketh_minter.did index 95c12594059d..3b70d47371b5 100644 --- a/rs/ethereum/cketh/minter/cketh_minter.did +++ b/rs/ethereum/cketh/minter/cketh_minter.did @@ -109,6 +109,9 @@ type InitArg = record { // with the Ethereum blockchain. If not specified, uses the production or // staging EVM RPC canister based on the ethereum_network field. evm_rpc_id : opt principal; + + // Address of the sweeper smart contract. + ethereum_sweeper_contract_address : opt text; }; type UpgradeArg = record { @@ -143,6 +146,9 @@ type UpgradeArg = record { // Change the last scraped block number of the deposit with subaccount helper smart contract. last_deposit_with_subaccount_scraped_block_number : opt nat; + + // Change the sweeper smart contract address. + ethereum_sweeper_contract_address : opt text; }; type MinterArg = variant { UpgradeArg : UpgradeArg; InitArg : InitArg }; diff --git a/rs/ethereum/cketh/minter/src/dashboard/tests.rs b/rs/ethereum/cketh/minter/src/dashboard/tests.rs index c2dd88646e51..df7d44f63e89 100644 --- a/rs/ethereum/cketh/minter/src/dashboard/tests.rs +++ b/rs/ethereum/cketh/minter/src/dashboard/tests.rs @@ -1146,6 +1146,7 @@ fn initial_state() -> State { next_transaction_nonce: TransactionNonce::ZERO.into(), last_scraped_block_number: candid::Nat::from(INITIAL_LAST_SCRAPED_BLOCK_NUMBER), evm_rpc_id: None, + ethereum_sweeper_contract_address: None, }) .expect("valid init args") } diff --git a/rs/ethereum/cketh/minter/src/lifecycle/init.rs b/rs/ethereum/cketh/minter/src/lifecycle/init.rs index 43dae5e6fa0b..96443bc35ce2 100644 --- a/rs/ethereum/cketh/minter/src/lifecycle/init.rs +++ b/rs/ethereum/cketh/minter/src/lifecycle/init.rs @@ -32,6 +32,8 @@ pub struct InitArg { pub last_scraped_block_number: Nat, #[cbor(n(9), with = "icrc_cbor::principal::option")] pub evm_rpc_id: Option, + #[n(10)] + pub ethereum_sweeper_contract_address: Option, } impl TryFrom for State { @@ -47,6 +49,7 @@ impl TryFrom for State { next_transaction_nonce, last_scraped_block_number, evm_rpc_id, + ethereum_sweeper_contract_address, }: InitArg, ) -> Result { use std::str::FromStr; @@ -62,6 +65,12 @@ impl TryFrom for State { .map_err(|e| { InvalidStateError::InvalidEthereumContractAddress(format!("ERROR: {e}")) })?; + let sweeper_contract_address = ethereum_sweeper_contract_address + .map(|a| Address::from_str(&a)) + .transpose() + .map_err(|e| { + InvalidStateError::InvalidEthereumContractAddress(format!("ERROR: {e}")) + })?; let last_scraped_block_number = BlockNumber::try_from(last_scraped_block_number) .map_err(|e| InvalidStateError::InvalidLastScrapedBlockNumber(format!("ERROR: {e}")))?; let first_scraped_block_number = @@ -110,6 +119,7 @@ impl TryFrom for State { erc20_balances: Default::default(), log_scrapings, automatic_deposits: AutomaticDeposits::default(), + sweeper_contract_address, }; state.validate_config()?; Ok(state) diff --git a/rs/ethereum/cketh/minter/src/lifecycle/tests.rs b/rs/ethereum/cketh/minter/src/lifecycle/tests.rs index bec6fcdf7fe9..f728fed6eff6 100644 --- a/rs/ethereum/cketh/minter/src/lifecycle/tests.rs +++ b/rs/ethereum/cketh/minter/src/lifecycle/tests.rs @@ -6,7 +6,9 @@ mod init { use crate::test_fixtures::valid_init_arg; use assert_matches::assert_matches; use candid::{Nat, Principal}; + use ic_ethereum_types::Address; use num_bigint::BigUint; + use std::str::FromStr; #[test] fn should_fail_when_init_args_invalid() { @@ -44,6 +46,24 @@ mod init { Err(InvalidStateError::InvalidEthereumContractAddress(_)) ); + assert_matches!( + State::try_from(InitArg { + ethereum_sweeper_contract_address: Some("invalid".to_string()), + ..valid_init_arg() + }), + Err(InvalidStateError::InvalidSweeperContractAddress(_)) + ); + + assert_matches!( + State::try_from(InitArg { + ethereum_sweeper_contract_address: Some( + "0x0000000000000000000000000000000000000000".to_string(), + ), + ..valid_init_arg() + }), + Err(InvalidStateError::InvalidSweeperContractAddress(_)) + ); + assert_matches!( State::try_from(InitArg { ledger_id: Principal::anonymous(), @@ -75,7 +95,12 @@ mod init { #[test] fn should_succeed() { - let init_arg = valid_init_arg(); + let init_arg = InitArg { + ethereum_sweeper_contract_address: Some( + "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34".to_string(), + ), + ..valid_init_arg() + }; let state = State::try_from(init_arg.clone()).expect("valid init args"); @@ -96,5 +121,9 @@ mod init { state.eth_transactions.next_transaction_nonce(), TransactionNonce::ZERO ); + assert_eq!( + state.sweeper_contract_address, + Some(Address::from_str("0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34").unwrap()) + ); } } diff --git a/rs/ethereum/cketh/minter/src/lifecycle/upgrade.rs b/rs/ethereum/cketh/minter/src/lifecycle/upgrade.rs index 609f555ee125..20b0e7daf35c 100644 --- a/rs/ethereum/cketh/minter/src/lifecycle/upgrade.rs +++ b/rs/ethereum/cketh/minter/src/lifecycle/upgrade.rs @@ -30,6 +30,8 @@ pub struct UpgradeArg { pub deposit_with_subaccount_helper_contract_address: Option, #[cbor(n(9), with = "icrc_cbor::nat::option")] pub last_deposit_with_subaccount_scraped_block_number: Option, + #[n(10)] + pub ethereum_sweeper_contract_address: Option, } pub fn post_upgrade(upgrade_args: Option) { diff --git a/rs/ethereum/cketh/minter/src/state.rs b/rs/ethereum/cketh/minter/src/state.rs index e43c17681a94..772afec51cff 100644 --- a/rs/ethereum/cketh/minter/src/state.rs +++ b/rs/ethereum/cketh/minter/src/state.rs @@ -25,6 +25,7 @@ use icrc_ledger_types::icrc1::account::Account; use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet, HashSet, btree_map}; use std::fmt::{Display, Formatter}; +use std::iter::once; use strum_macros::EnumIter; use transactions::EthTransactions; @@ -113,6 +114,10 @@ pub struct State { /// ckERC20 deposit addresses registered via `deposit_erc20`, individually /// derived for each user and watched for incoming deposits. pub automatic_deposits: AutomaticDeposits, + + /// Address of the sweeper smart contract on Ethereum, which the minter + /// delegates to when sweeping funded deposit addresses. + pub sweeper_contract_address: Option
, } #[derive(Eq, PartialEq, Debug)] @@ -122,6 +127,7 @@ pub enum InvalidStateError { InvalidLedgerId(String), InvalidEthereumContractAddress(String), InvalidErc20HelperContractAddress(String), + InvalidSweeperContractAddress(String), InvalidMinimumWithdrawalAmount(String), InvalidLastScrapedBlockNumber(String), InvalidLastErc20ScrapedBlockNumber(String), @@ -474,6 +480,7 @@ impl State { evm_rpc_id, deposit_with_subaccount_helper_contract_address, last_deposit_with_subaccount_scraped_block_number, + ethereum_sweeper_contract_address, } = upgrade_args; if let Some(nonce) = next_transaction_nonce { let nonce = TransactionNonce::try_from(nonce) @@ -547,6 +554,12 @@ impl State { if let Some(evm_id) = evm_rpc_id { self.evm_rpc_id = evm_id; } + if let Some(address) = ethereum_sweeper_contract_address { + let address = Address::from_str(&address).map_err(|e| { + InvalidStateError::InvalidSweeperContractAddress(format!("ERROR: {e}")) + })?; + self.sweeper_contract_address = Some(address); + } self.validate_config() } @@ -583,6 +596,10 @@ impl State { ); ensure_eq!(self.ckerc20_tokens, other.ckerc20_tokens); ensure_eq!(self.automatic_deposits, other.automatic_deposits); + ensure_eq!( + self.sweeper_contract_address, + other.sweeper_contract_address + ); self.eth_transactions .is_equivalent_to(&other.eth_transactions) diff --git a/rs/ethereum/cketh/minter/src/state/tests.rs b/rs/ethereum/cketh/minter/src/state/tests.rs index b5b7d2de7a2d..24a8fadba4a2 100644 --- a/rs/ethereum/cketh/minter/src/state/tests.rs +++ b/rs/ethereum/cketh/minter/src/state/tests.rs @@ -357,6 +357,26 @@ mod upgrade { }), Err(InvalidStateError::InvalidEthereumContractAddress(_)) ); + + let mut state = initial_state(); + assert_matches!( + state.upgrade(UpgradeArg { + ethereum_sweeper_contract_address: Some("invalid".to_string()), + ..Default::default() + }), + Err(InvalidStateError::InvalidSweeperContractAddress(_)) + ); + + let mut state = initial_state(); + assert_matches!( + state.upgrade(UpgradeArg { + ethereum_sweeper_contract_address: Some( + "0x0000000000000000000000000000000000000000".to_string(), + ), + ..Default::default() + }), + Err(InvalidStateError::InvalidSweeperContractAddress(_)) + ); } #[test] @@ -370,6 +390,9 @@ mod upgrade { "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34".to_string(), ), ethereum_block_height: Some(CandidBlockTag::Safe), + ethereum_sweeper_contract_address: Some( + "0x2D39863d30716aaf2B7fFFd85Dd03Dda2BFC2E38".to_string(), + ), ..Default::default() }; @@ -390,6 +413,10 @@ mod upgrade { Some(&Address::from_str("0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34").unwrap()) ); assert_eq!(state.ethereum_block_height, CandidBlockTag::Safe); + assert_eq!( + state.sweeper_contract_address, + Some(Address::from_str("0x2D39863d30716aaf2B7fFFd85Dd03Dda2BFC2E38").unwrap()) + ); } } @@ -576,6 +603,7 @@ prop_compose! { ecdsa_key_name in "[a-z_]*", last_scraped_block_number in arb_nat(), evm_rpc_id in proptest::option::of(arb_principal()), + sweeper_contract_address in proptest::option::of(arb_address()), ) -> InitArg { InitArg { ethereum_network: EthereumNetwork::Sepolia, @@ -587,6 +615,7 @@ prop_compose! { next_transaction_nonce, last_scraped_block_number, evm_rpc_id, + ethereum_sweeper_contract_address: sweeper_contract_address.map(|addr| addr.to_string()), } } } @@ -603,6 +632,7 @@ prop_compose! { evm_rpc_id in proptest::option::of(arb_principal()), deposit_with_subaccount_helper_contract_address in proptest::option::of(arb_address()), last_deposit_with_subaccount_scraped_block_number in proptest::option::of(arb_nat()), + sweeper_contract_address in proptest::option::of(arb_address()), ) -> UpgradeArg { UpgradeArg { ethereum_contract_address: contract_address.map(|addr| addr.to_string()), @@ -614,7 +644,8 @@ prop_compose! { last_erc20_scraped_block_number, evm_rpc_id, deposit_with_subaccount_helper_contract_address: deposit_with_subaccount_helper_contract_address.map(|addr| addr.to_string()), - last_deposit_with_subaccount_scraped_block_number + last_deposit_with_subaccount_scraped_block_number, + ethereum_sweeper_contract_address: sweeper_contract_address.map(|addr| addr.to_string()), } } } @@ -1076,6 +1107,11 @@ fn state_equivalence() { evm_rpc_id: EVM_RPC_ID_PRODUCTION, ckerc20_tokens, automatic_deposits, + sweeper_contract_address: Some( + "0x2D39863d30716aaf2B7fFFd85Dd03Dda2BFC2E38" + .parse() + .unwrap(), + ), }; assert_eq!( @@ -1169,6 +1205,15 @@ fn state_equivalence() { "changing essential fields should break equivalence", ); + assert_ne!( + Ok(()), + state.is_equivalent_to(&State { + sweeper_contract_address: None, + ..state.clone() + }), + "changing essential fields should break equivalence", + ); + assert_eq!( Ok(()), state.is_equivalent_to(&State { diff --git a/rs/ethereum/cketh/minter/src/test_fixtures.rs b/rs/ethereum/cketh/minter/src/test_fixtures.rs index c9d77c9c7318..4a47f86790f1 100644 --- a/rs/ethereum/cketh/minter/src/test_fixtures.rs +++ b/rs/ethereum/cketh/minter/src/test_fixtures.rs @@ -48,6 +48,7 @@ pub fn valid_init_arg() -> InitArg { next_transaction_nonce: Default::default(), last_scraped_block_number: Default::default(), evm_rpc_id: Some(EVM_RPC_ID_STAGING), + ethereum_sweeper_contract_address: None, } } diff --git a/rs/ethereum/cketh/test_utils/src/lib.rs b/rs/ethereum/cketh/test_utils/src/lib.rs index 37248e4c6f30..fa0fe5ac1912 100644 --- a/rs/ethereum/cketh/test_utils/src/lib.rs +++ b/rs/ethereum/cketh/test_utils/src/lib.rs @@ -807,6 +807,7 @@ fn install_minter( minimum_withdrawal_amount: CKETH_MINIMUM_WITHDRAWAL_AMOUNT.into(), last_scraped_block_number: LAST_SCRAPED_BLOCK_NUMBER_AT_INSTALL.into(), evm_rpc_id: Some(evm_rpc_id), + ethereum_sweeper_contract_address: None, }; let minter_arg = MinterArg::InitArg(args); env.install_canister( diff --git a/rs/ethereum/cketh/test_utils/src/live_scan.rs b/rs/ethereum/cketh/test_utils/src/live_scan.rs index bde3c936c3ef..29d2f04fc752 100644 --- a/rs/ethereum/cketh/test_utils/src/live_scan.rs +++ b/rs/ethereum/cketh/test_utils/src/live_scan.rs @@ -263,6 +263,7 @@ fn install_minter( next_transaction_nonce: Nat::from(0_u8), last_scraped_block_number: Nat::from(0_u8), evm_rpc_id: Some(evm_rpc_id), + ethereum_sweeper_contract_address: None, }; env.install_canister( minter_id, diff --git a/rs/tests/cross_chain/ic_xc_cketh_test.rs b/rs/tests/cross_chain/ic_xc_cketh_test.rs index 7aa65febe906..182af4c7f819 100644 --- a/rs/tests/cross_chain/ic_xc_cketh_test.rs +++ b/rs/tests/cross_chain/ic_xc_cketh_test.rs @@ -362,6 +362,7 @@ fn minter_init_args(ecdsa_key_id: &EcdsaKeyId, cketh_ledger: Principal) -> Minte next_transaction_nonce: Nat::from(0_u8), last_scraped_block_number: Nat::from(0_u8), evm_rpc_id: None, + ethereum_sweeper_contract_address: None, } } From 5c0d55d43ebe41319aa97bbde704c28adcc69bac Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Fri, 14 Aug 2026 13:51:58 +0200 Subject: [PATCH 2/3] refactor(cketh): validate all contract addresses in one place Collapse the per-contract `InvalidStateError` variants into a single `InvalidContractAddress`, and move both address rules into `validate_config`: no configured contract address may be zero, and no two contracts may share an address. Since the zero check no longer lives in the log scraping state, setting a contract address becomes infallible and `LogScrapingStateError` goes away, removing the error mapping at every call site. Co-Authored-By: Claude Opus 5 (1M context) --- .../cketh/minter/src/dashboard/tests.rs | 3 +- .../cketh/minter/src/eth_logs/tests.rs | 11 +-- .../cketh/minter/src/lifecycle/init.rs | 13 +--- .../cketh/minter/src/lifecycle/tests.rs | 21 +++++- rs/ethereum/cketh/minter/src/state.rs | 73 ++++++++++--------- .../minter/src/state/eth_logs_scraping/mod.rs | 22 +----- .../src/state/eth_logs_scraping/tests.rs | 34 ++++----- rs/ethereum/cketh/minter/src/state/tests.rs | 42 ++++++++++- 8 files changed, 116 insertions(+), 103 deletions(-) diff --git a/rs/ethereum/cketh/minter/src/dashboard/tests.rs b/rs/ethereum/cketh/minter/src/dashboard/tests.rs index df7d44f63e89..f5fc38ce889b 100644 --- a/rs/ethereum/cketh/minter/src/dashboard/tests.rs +++ b/rs/ethereum/cketh/minter/src/dashboard/tests.rs @@ -147,8 +147,7 @@ fn should_display_helper_smart_contracts() { ) { dashboard .log_scrapings - .set_contract_address(id, contract_address.parse().unwrap()) - .unwrap(); + .set_contract_address(id, contract_address.parse().unwrap()); dashboard .log_scrapings .set_last_scraped_block_number(id, BlockNumber::from(last_scraped_block_number)); diff --git a/rs/ethereum/cketh/minter/src/eth_logs/tests.rs b/rs/ethereum/cketh/minter/src/eth_logs/tests.rs index 1671f64591d8..cee585f137b5 100644 --- a/rs/ethereum/cketh/minter/src/eth_logs/tests.rs +++ b/rs/ethereum/cketh/minter/src/eth_logs/tests.rs @@ -277,13 +277,10 @@ mod scraping { let last_scraped_block_number = BlockNumber::from(6_970_446_u32); let state = { let mut state = initial_state(); - state - .log_scrapings - .set_contract_address( - LogScrapingId::EthOrErc20DepositWithSubaccount, - CONTRACT_ADDRESS, - ) - .unwrap(); + state.log_scrapings.set_contract_address( + LogScrapingId::EthOrErc20DepositWithSubaccount, + CONTRACT_ADDRESS, + ); state.log_scrapings.set_last_scraped_block_number( LogScrapingId::EthOrErc20DepositWithSubaccount, last_scraped_block_number, diff --git a/rs/ethereum/cketh/minter/src/lifecycle/init.rs b/rs/ethereum/cketh/minter/src/lifecycle/init.rs index 96443bc35ce2..1d2b319d3a87 100644 --- a/rs/ethereum/cketh/minter/src/lifecycle/init.rs +++ b/rs/ethereum/cketh/minter/src/lifecycle/init.rs @@ -62,15 +62,11 @@ impl TryFrom for State { let eth_helper_contract_address = ethereum_contract_address .map(|a| Address::from_str(&a)) .transpose() - .map_err(|e| { - InvalidStateError::InvalidEthereumContractAddress(format!("ERROR: {e}")) - })?; + .map_err(|e| InvalidStateError::InvalidContractAddress(format!("ERROR: {e}")))?; let sweeper_contract_address = ethereum_sweeper_contract_address .map(|a| Address::from_str(&a)) .transpose() - .map_err(|e| { - InvalidStateError::InvalidEthereumContractAddress(format!("ERROR: {e}")) - })?; + .map_err(|e| InvalidStateError::InvalidContractAddress(format!("ERROR: {e}")))?; let last_scraped_block_number = BlockNumber::try_from(last_scraped_block_number) .map_err(|e| InvalidStateError::InvalidLastScrapedBlockNumber(format!("ERROR: {e}")))?; let first_scraped_block_number = @@ -88,10 +84,7 @@ impl TryFrom for State { let mut log_scrapings = LogScrapings::new(last_scraped_block_number); if let Some(contract_address) = eth_helper_contract_address { log_scrapings - .set_contract_address(LogScrapingId::EthDepositWithoutSubaccount, contract_address) - .map_err(|e| { - InvalidStateError::InvalidEthereumContractAddress(format!("ERROR: {e:?}")) - })?; + .set_contract_address(LogScrapingId::EthDepositWithoutSubaccount, contract_address); } let state = Self { ethereum_network, diff --git a/rs/ethereum/cketh/minter/src/lifecycle/tests.rs b/rs/ethereum/cketh/minter/src/lifecycle/tests.rs index f728fed6eff6..efe827cdc68d 100644 --- a/rs/ethereum/cketh/minter/src/lifecycle/tests.rs +++ b/rs/ethereum/cketh/minter/src/lifecycle/tests.rs @@ -33,7 +33,7 @@ mod init { ethereum_contract_address: Some("invalid".to_string()), ..valid_init_arg() }), - Err(InvalidStateError::InvalidEthereumContractAddress(_)) + Err(InvalidStateError::InvalidContractAddress(_)) ); assert_matches!( @@ -43,7 +43,7 @@ mod init { ), ..valid_init_arg() }), - Err(InvalidStateError::InvalidEthereumContractAddress(_)) + Err(InvalidStateError::InvalidContractAddress(_)) ); assert_matches!( @@ -51,7 +51,7 @@ mod init { ethereum_sweeper_contract_address: Some("invalid".to_string()), ..valid_init_arg() }), - Err(InvalidStateError::InvalidSweeperContractAddress(_)) + Err(InvalidStateError::InvalidContractAddress(_)) ); assert_matches!( @@ -61,7 +61,20 @@ mod init { ), ..valid_init_arg() }), - Err(InvalidStateError::InvalidSweeperContractAddress(_)) + Err(InvalidStateError::InvalidContractAddress(_)) + ); + + assert_matches!( + State::try_from(InitArg { + ethereum_contract_address: Some( + "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34".to_string(), + ), + ethereum_sweeper_contract_address: Some( + "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34".to_string(), + ), + ..valid_init_arg() + }), + Err(InvalidStateError::InvalidContractAddress(_)) ); assert_matches!( diff --git a/rs/ethereum/cketh/minter/src/state.rs b/rs/ethereum/cketh/minter/src/state.rs index 772afec51cff..77cb54d238b5 100644 --- a/rs/ethereum/cketh/minter/src/state.rs +++ b/rs/ethereum/cketh/minter/src/state.rs @@ -25,7 +25,6 @@ use icrc_ledger_types::icrc1::account::Account; use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet, HashSet, btree_map}; use std::fmt::{Display, Formatter}; -use std::iter::once; use strum_macros::EnumIter; use transactions::EthTransactions; @@ -125,9 +124,7 @@ pub enum InvalidStateError { InvalidTransactionNonce(String), InvalidEcdsaKeyName(String), InvalidLedgerId(String), - InvalidEthereumContractAddress(String), - InvalidErc20HelperContractAddress(String), - InvalidSweeperContractAddress(String), + InvalidContractAddress(String), InvalidMinimumWithdrawalAmount(String), InvalidLastScrapedBlockNumber(String), InvalidLastErc20ScrapedBlockNumber(String), @@ -188,6 +185,25 @@ impl State { .to_string(), )); } + + let mut seen_contract_addresses = BTreeSet::new(); + for address in self + .log_scrapings + .iter() + .filter_map(|(_id, scraping)| scraping.contract_address()) + .chain(self.sweeper_contract_address.as_ref()) + { + if address == &Address::ZERO { + return Err(InvalidStateError::InvalidContractAddress( + "contract address must not be zero".to_string(), + )); + } + if !seen_contract_addresses.insert(address) { + return Err(InvalidStateError::InvalidContractAddress(format!( + "contract address {address} is used by more than one contract" + ))); + } + } Ok(()) } @@ -494,30 +510,20 @@ impl State { self.cketh_minimum_withdrawal_amount = minimum_withdrawal_amount; } if let Some(address) = ethereum_contract_address { - let eth_helper_contract_address = Address::from_str(&address).map_err(|e| { - InvalidStateError::InvalidEthereumContractAddress(format!("ERROR: {e}")) - })?; - self.log_scrapings - .set_contract_address( - LogScrapingId::EthDepositWithoutSubaccount, - eth_helper_contract_address, - ) - .map_err(|e| { - InvalidStateError::InvalidEthereumContractAddress(format!("ERROR: {e:?}")) - })?; + let eth_helper_contract_address = Address::from_str(&address) + .map_err(|e| InvalidStateError::InvalidContractAddress(format!("ERROR: {e}")))?; + self.log_scrapings.set_contract_address( + LogScrapingId::EthDepositWithoutSubaccount, + eth_helper_contract_address, + ); } if let Some(address) = erc20_helper_contract_address { - let erc20_helper_contract_address = Address::from_str(&address).map_err(|e| { - InvalidStateError::InvalidErc20HelperContractAddress(format!("ERROR: {e}")) - })?; - self.log_scrapings - .set_contract_address( - LogScrapingId::Erc20DepositWithoutSubaccount, - erc20_helper_contract_address, - ) - .map_err(|e| { - InvalidStateError::InvalidEthereumContractAddress(format!("ERROR: {e:?}")) - })?; + let erc20_helper_contract_address = Address::from_str(&address) + .map_err(|e| InvalidStateError::InvalidContractAddress(format!("ERROR: {e}")))?; + self.log_scrapings.set_contract_address( + LogScrapingId::Erc20DepositWithoutSubaccount, + erc20_helper_contract_address, + ); } if let Some(block_number) = last_erc20_scraped_block_number { self.log_scrapings.set_last_scraped_block_number( @@ -528,14 +534,10 @@ impl State { ); } if let Some(address) = deposit_with_subaccount_helper_contract_address { - let address = Address::from_str(&address).map_err(|e| { - InvalidStateError::InvalidErc20HelperContractAddress(format!("ERROR: {e}")) - })?; + let address = Address::from_str(&address) + .map_err(|e| InvalidStateError::InvalidContractAddress(format!("ERROR: {e}")))?; self.log_scrapings - .set_contract_address(LogScrapingId::EthOrErc20DepositWithSubaccount, address) - .map_err(|e| { - InvalidStateError::InvalidEthereumContractAddress(format!("ERROR: {e:?}")) - })?; + .set_contract_address(LogScrapingId::EthOrErc20DepositWithSubaccount, address); } if let Some(block_number) = last_deposit_with_subaccount_scraped_block_number { self.log_scrapings.set_last_scraped_block_number( @@ -555,9 +557,8 @@ impl State { self.evm_rpc_id = evm_id; } if let Some(address) = ethereum_sweeper_contract_address { - let address = Address::from_str(&address).map_err(|e| { - InvalidStateError::InvalidSweeperContractAddress(format!("ERROR: {e}")) - })?; + let address = Address::from_str(&address) + .map_err(|e| InvalidStateError::InvalidContractAddress(format!("ERROR: {e}")))?; self.sweeper_contract_address = Some(address); } self.validate_config() diff --git a/rs/ethereum/cketh/minter/src/state/eth_logs_scraping/mod.rs b/rs/ethereum/cketh/minter/src/state/eth_logs_scraping/mod.rs index cfc017acfd7f..6ea7cb2949a0 100644 --- a/rs/ethereum/cketh/minter/src/state/eth_logs_scraping/mod.rs +++ b/rs/ethereum/cketh/minter/src/state/eth_logs_scraping/mod.rs @@ -31,11 +31,7 @@ impl LogScrapings { self.scrapings.iter() } - pub fn set_contract_address( - &mut self, - id: LogScrapingId, - contract_address: Address, - ) -> Result<(), LogScrapingStateError> { + pub fn set_contract_address(&mut self, id: LogScrapingId, contract_address: Address) { self.get_mut(id).set_contract_address(contract_address) } @@ -118,11 +114,6 @@ impl Display for LogScrapingId { } } -#[derive(Clone, PartialEq, Debug)] -pub enum LogScrapingStateError { - InvalidContractAddress(String), -} - #[derive(Clone, Copy, PartialEq, Debug)] #[repr(u8)] pub enum LogScrapingStatus { @@ -159,17 +150,8 @@ impl LogScrapingState { } } - pub fn set_contract_address( - &mut self, - contract_address: Address, - ) -> Result<(), LogScrapingStateError> { - if contract_address == Address::ZERO { - return Err(LogScrapingStateError::InvalidContractAddress( - "contract address must not be zero".to_string(), - )); - } + pub fn set_contract_address(&mut self, contract_address: Address) { self.contract_address = Some(contract_address); - Ok(()) } pub fn set_last_scraped_block_number(&mut self, block_number: BlockNumber) { diff --git a/rs/ethereum/cketh/minter/src/state/eth_logs_scraping/tests.rs b/rs/ethereum/cketh/minter/src/state/eth_logs_scraping/tests.rs index 4eeec72e5b28..0ecdf8a1065c 100644 --- a/rs/ethereum/cketh/minter/src/state/eth_logs_scraping/tests.rs +++ b/rs/ethereum/cketh/minter/src/state/eth_logs_scraping/tests.rs @@ -22,18 +22,14 @@ fn should_not_change_other_field_when_deposit_with_subaccount_present() { } ); - scrapings - .set_contract_address( - LogScrapingId::EthDepositWithoutSubaccount, - ETH_HELPER_SMART_CONTRACT.parse().unwrap(), - ) - .unwrap(); - scrapings - .set_contract_address( - LogScrapingId::Erc20DepositWithoutSubaccount, - ERC20_HELPER_SMART_CONTRACT.parse().unwrap(), - ) - .unwrap(); + scrapings.set_contract_address( + LogScrapingId::EthDepositWithoutSubaccount, + ETH_HELPER_SMART_CONTRACT.parse().unwrap(), + ); + scrapings.set_contract_address( + LogScrapingId::Erc20DepositWithoutSubaccount, + ERC20_HELPER_SMART_CONTRACT.parse().unwrap(), + ); let info_before = scrapings.info(); assert_eq!( @@ -50,14 +46,12 @@ fn should_not_change_other_field_when_deposit_with_subaccount_present() { } ); - scrapings - .set_contract_address( - LogScrapingId::EthOrErc20DepositWithSubaccount, - DEPOSIT_WITH_SUBACCOUNT_HELPER_SMART_CONTRACT - .parse() - .unwrap(), - ) - .unwrap(); + scrapings.set_contract_address( + LogScrapingId::EthOrErc20DepositWithSubaccount, + DEPOSIT_WITH_SUBACCOUNT_HELPER_SMART_CONTRACT + .parse() + .unwrap(), + ); scrapings.set_last_scraped_block_number( LogScrapingId::EthOrErc20DepositWithSubaccount, (LAST_SCRAPED_BLOCK_NUMBER + 1).into(), diff --git a/rs/ethereum/cketh/minter/src/state/tests.rs b/rs/ethereum/cketh/minter/src/state/tests.rs index 24a8fadba4a2..d23788e56571 100644 --- a/rs/ethereum/cketh/minter/src/state/tests.rs +++ b/rs/ethereum/cketh/minter/src/state/tests.rs @@ -344,7 +344,7 @@ mod upgrade { ethereum_contract_address: Some("invalid".to_string()), ..Default::default() }), - Err(InvalidStateError::InvalidEthereumContractAddress(_)) + Err(InvalidStateError::InvalidContractAddress(_)) ); let mut state = initial_state(); @@ -355,7 +355,7 @@ mod upgrade { ), ..Default::default() }), - Err(InvalidStateError::InvalidEthereumContractAddress(_)) + Err(InvalidStateError::InvalidContractAddress(_)) ); let mut state = initial_state(); @@ -364,7 +364,7 @@ mod upgrade { ethereum_sweeper_contract_address: Some("invalid".to_string()), ..Default::default() }), - Err(InvalidStateError::InvalidSweeperContractAddress(_)) + Err(InvalidStateError::InvalidContractAddress(_)) ); let mut state = initial_state(); @@ -375,7 +375,41 @@ mod upgrade { ), ..Default::default() }), - Err(InvalidStateError::InvalidSweeperContractAddress(_)) + Err(InvalidStateError::InvalidContractAddress(_)) + ); + + let mut state = initial_state(); + assert_matches!( + state.upgrade(UpgradeArg { + erc20_helper_contract_address: Some( + "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34".to_string(), + ), + ethereum_sweeper_contract_address: Some( + "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34".to_string(), + ), + ..Default::default() + }), + Err(InvalidStateError::InvalidContractAddress(_)) + ); + + let mut state = initial_state(); + state + .upgrade(UpgradeArg { + ethereum_contract_address: Some( + "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34".to_string(), + ), + ..Default::default() + }) + .expect("valid upgrade args"); + assert_matches!( + state.upgrade(UpgradeArg { + erc20_helper_contract_address: Some( + "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34".to_string(), + ), + ..Default::default() + }), + Err(InvalidStateError::InvalidContractAddress(_)), + "a contract address already set by an earlier upgrade must stay distinct" ); } From 88530b6871a8a08dd7bab970617cacf513231034 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Fri, 14 Aug 2026 14:08:13 +0200 Subject: [PATCH 3/3] feat(cketh): expose the sweeper contract address Surface the configured sweeper smart contract address in the `get_minter_info` endpoint and in the Metadata section of the minter dashboard, so it can be inspected the same way as the minter address and the deposit helper contracts. The dashboard row is omitted when no sweeper contract is configured. Co-Authored-By: Claude Opus 5 (1M context) --- rs/ethereum/cketh/minter/cketh_minter.did | 3 +++ rs/ethereum/cketh/minter/src/dashboard.rs | 2 ++ .../cketh/minter/src/dashboard/tests.rs | 21 ++++++++++++++++++- rs/ethereum/cketh/minter/src/endpoints.rs | 1 + rs/ethereum/cketh/minter/src/main.rs | 1 + .../cketh/minter/templates/dashboard.html | 6 ++++++ rs/ethereum/cketh/minter/tests/ckerc20.rs | 1 + rs/ethereum/cketh/minter/tests/cketh.rs | 19 +++++++++++++++++ 8 files changed, 53 insertions(+), 1 deletion(-) diff --git a/rs/ethereum/cketh/minter/cketh_minter.did b/rs/ethereum/cketh/minter/cketh_minter.did index 3b70d47371b5..c984ac2a8401 100644 --- a/rs/ethereum/cketh/minter/cketh_minter.did +++ b/rs/ethereum/cketh/minter/cketh_minter.did @@ -208,6 +208,9 @@ type MinterInfo = record { // Address of the ETH or ERC20 deposit with subaccount helper smart contract. deposit_with_subaccount_helper_contract_address: opt text; + // Address of the sweeper smart contract. + sweeper_contract_address: opt text; + // Information of supported ERC20 tokens. supported_ckerc20_tokens: opt vec CkErc20Token; diff --git a/rs/ethereum/cketh/minter/src/dashboard.rs b/rs/ethereum/cketh/minter/src/dashboard.rs index 9e4908e037ca..d64afd0c3903 100644 --- a/rs/ethereum/cketh/minter/src/dashboard.rs +++ b/rs/ethereum/cketh/minter/src/dashboard.rs @@ -279,6 +279,7 @@ pub struct DashboardTemplate { pub ethereum_network: EthereumNetwork, pub ecdsa_key_name: String, pub minter_address: String, + pub sweeper_contract_address: Option
, pub log_scrapings: LogScrapings, pub next_transaction_nonce: TransactionNonce, pub minimum_withdrawal_amount: Wei, @@ -482,6 +483,7 @@ impl DashboardTemplate { .minter_address() .map(|addr| addr.to_string()) .unwrap_or_default(), + sweeper_contract_address: state.sweeper_contract_address, log_scrapings: state.log_scrapings.clone(), cketh_ledger_id: state.cketh_ledger_id, next_transaction_nonce: state.eth_transactions.next_transaction_nonce(), diff --git a/rs/ethereum/cketh/minter/src/dashboard/tests.rs b/rs/ethereum/cketh/minter/src/dashboard/tests.rs index f5fc38ce889b..b6adf2332b7e 100644 --- a/rs/ethereum/cketh/minter/src/dashboard/tests.rs +++ b/rs/ethereum/cketh/minter/src/dashboard/tests.rs @@ -47,7 +47,18 @@ fn should_display_metadata() { .has_minimum_withdrawal_amount("10_000_000_000_000_000") .has_eth_balance("0") .has_total_effective_tx_fees("0") - .has_total_unspent_tx_fees("0"); + .has_total_unspent_tx_fees("0") + .has_no_elements_matching("#sweeper-contract-address"); + + let dashboard = DashboardTemplate { + sweeper_contract_address: Some( + Address::from_str("0x2D39863d30716aaf2B7fFFd85Dd03Dda2BFC2E38").unwrap(), + ), + ..dashboard + }; + + DashboardAssert::assert_that(dashboard) + .has_sweeper_contract_address("0x2D39863d30716aaf2B7fFFd85Dd03Dda2BFC2E38"); } #[test] @@ -1611,6 +1622,14 @@ mod assertions { ) } + pub fn has_sweeper_contract_address(&self, expected_address: &str) -> &Self { + self.has_string_value( + "#sweeper-contract-address > td", + expected_address, + "wrong sweeper contract address", + ) + } + pub fn has_helper_contract( &self, id: LogScrapingId, diff --git a/rs/ethereum/cketh/minter/src/endpoints.rs b/rs/ethereum/cketh/minter/src/endpoints.rs index 98cf64b54aa3..9358f32320bb 100644 --- a/rs/ethereum/cketh/minter/src/endpoints.rs +++ b/rs/ethereum/cketh/minter/src/endpoints.rs @@ -71,6 +71,7 @@ pub struct MinterInfo { pub eth_helper_contract_address: Option, pub erc20_helper_contract_address: Option, pub deposit_with_subaccount_helper_contract_address: Option, + pub sweeper_contract_address: Option, pub supported_ckerc20_tokens: Option>, pub minimum_withdrawal_amount: Option, pub ethereum_block_height: Option, diff --git a/rs/ethereum/cketh/minter/src/main.rs b/rs/ethereum/cketh/minter/src/main.rs index 90d848c130f3..d2ec5769c55c 100644 --- a/rs/ethereum/cketh/minter/src/main.rs +++ b/rs/ethereum/cketh/minter/src/main.rs @@ -290,6 +290,7 @@ async fn get_minter_info() -> MinterInfo { eth_helper_contract_address, erc20_helper_contract_address, deposit_with_subaccount_helper_contract_address, + sweeper_contract_address: s.sweeper_contract_address.map(|a| a.to_string()), supported_ckerc20_tokens, minimum_withdrawal_amount: Some(s.cketh_minimum_withdrawal_amount.into()), ethereum_block_height: Some(s.ethereum_block_height.clone()), diff --git a/rs/ethereum/cketh/minter/templates/dashboard.html b/rs/ethereum/cketh/minter/templates/dashboard.html index ca6d37d9e7f4..6514ff926fa7 100644 --- a/rs/ethereum/cketh/minter/templates/dashboard.html +++ b/rs/ethereum/cketh/minter/templates/dashboard.html @@ -111,6 +111,12 @@

Metadata

Minter address {% call etherscan_address_link(minter_address) %}{% endcall %} + {% if sweeper_contract_address.is_some() -%} + + Sweeper contract address + {% call etherscan_address_link(sweeper_contract_address.unwrap()) %}{% endcall %} + + {%- endif %} ckETH ledger canister ID {{ cketh_ledger_id }} diff --git a/rs/ethereum/cketh/minter/tests/ckerc20.rs b/rs/ethereum/cketh/minter/tests/ckerc20.rs index 39d4fab975d1..32a6ea3f7c22 100644 --- a/rs/ethereum/cketh/minter/tests/ckerc20.rs +++ b/rs/ethereum/cketh/minter/tests/ckerc20.rs @@ -2058,6 +2058,7 @@ fn should_retrieve_minter_info() { ERC20_HELPER_CONTRACT_ADDRESS )), deposit_with_subaccount_helper_contract_address: None, + sweeper_contract_address: None, supported_ckerc20_tokens: Some(supported_ckerc20_tokens), minimum_withdrawal_amount: Some(Nat::from(CKETH_MINIMUM_WITHDRAWAL_AMOUNT)), ethereum_block_height: Some(Finalized), diff --git a/rs/ethereum/cketh/minter/tests/cketh.rs b/rs/ethereum/cketh/minter/tests/cketh.rs index 68d569fb3c9e..2fbc9ce804fb 100644 --- a/rs/ethereum/cketh/minter/tests/cketh.rs +++ b/rs/ethereum/cketh/minter/tests/cketh.rs @@ -1211,6 +1211,7 @@ fn should_retrieve_minter_info() { )), erc20_helper_contract_address: None, deposit_with_subaccount_helper_contract_address: None, + sweeper_contract_address: None, supported_ckerc20_tokens: None, minimum_withdrawal_amount: Some(Nat::from(CKETH_MINIMUM_WITHDRAWAL_AMOUNT)), ethereum_block_height: Some(Finalized), @@ -1270,6 +1271,24 @@ fn should_retrieve_minter_info() { ); } +#[test] +fn should_retrieve_sweeper_contract_address_after_upgrade() { + const SWEEPER_CONTRACT_ADDRESS: &str = "0x2D39863d30716aaf2B7fFFd85Dd03Dda2BFC2E38"; + + let cketh = CkEthSetup::default(); + assert_eq!(cketh.get_minter_info().sweeper_contract_address, None); + + let cketh = cketh.check_audit_logs_and_upgrade(UpgradeArg { + ethereum_sweeper_contract_address: Some(SWEEPER_CONTRACT_ADDRESS.to_string()), + ..Default::default() + }); + + assert_eq!( + cketh.get_minter_info().sweeper_contract_address, + Some(format_ethereum_address_to_eip_55(SWEEPER_CONTRACT_ADDRESS)) + ); +} + fn format_ethereum_address_to_eip_55(address: &str) -> String { Address::from_str(address).unwrap().to_string() }