Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5f853e6
test(cketh): build the live balance-scan harness on the shared fixture
gregorydemay Aug 13, 2026
5c6bda6
refactor(cketh): thread the install sender through the shared installers
gregorydemay Aug 13, 2026
c2053c6
fix(cketh): restore ECDSA_KEY_NAME as a single shared constant
gregorydemay Aug 13, 2026
f683e8c
fix(cketh): correct the documented cause of the live-mode crash
gregorydemay Aug 13, 2026
7e84561
refactor(cketh): rename EvmRpcBackend to EthereumBackend
gregorydemay Aug 13, 2026
04d586b
docs(cketh): explain why Mocked implies Finalized/LAST_SCRAPED_BLOCK_…
gregorydemay Aug 13, 2026
206bb5c
docs(cketh): scope CkEthCanisters' doc to how each fixture actually u…
gregorydemay Aug 13, 2026
d14bc70
docs(cketh): tighten the cycles-headroom comment on the live harness
gregorydemay Aug 13, 2026
33d34d1
refactor(cketh): build the live balance-scan harness on CkEthSetup it…
gregorydemay Aug 13, 2026
651d03a
refactor(cketh): collapse build_mocked/build_live into one build sequ…
gregorydemay Aug 13, 2026
ad8ce46
fix(cketh): restore the cycles-headroom rationale as a named constant
gregorydemay Aug 13, 2026
5d619c3
docs(cketh): lead the ledger conditional with the reason, not the obs…
gregorydemay Aug 13, 2026
a114ffd
refactor(cketh): reuse CkEthSetup::add_ckerc20_token in register_supp…
gregorydemay Aug 13, 2026
501c22f
refactor(cketh): apply the rate-limiting config to both new_env modes
gregorydemay Aug 13, 2026
d000a55
refactor(cketh): name new_env's modes with an EnvMode enum
gregorydemay Aug 13, 2026
78aa809
fix(cketh): assert with_env and with_live_mode are never combined
gregorydemay Aug 13, 2026
986fd38
docs(cketh): fix CkEthCanisters' doc referencing the removed build_mo…
gregorydemay Aug 13, 2026
336bc52
refactor(cketh): derive the fixture's live mode from EthereumBackend
gregorydemay Aug 13, 2026
7ebe033
refactor(cketh): hold the anvil node itself in EthereumBackend::Anvil
gregorydemay Aug 13, 2026
75f3d8e
refactor(cketh): inline pocket_ic_builder into its one caller, new_env
gregorydemay Aug 13, 2026
0a4ea72
refactor(cketh): let the fixture build the PocketIC instance it needs
gregorydemay Aug 13, 2026
8a44389
refactor(cketh): create and install the canisters the same way for ev…
gregorydemay Aug 13, 2026
a87674b
refactor(cketh): build the live balance-scan harness on CkErc20Setup
gregorydemay Aug 13, 2026
96d3b0b
Merge origin/ic_DEFI-2262_8_live-scan-setup
gregorydemay Aug 13, 2026
7a5682b
fix(cketh): build the live fixture on a non-live instance, go live last
gregorydemay Aug 14, 2026
8aff162
refactor(cketh): delete CkEthSetupBuilder, fold it into CkEthSetup::new
gregorydemay Aug 14, 2026
3cb7de4
Merge master into ic_DEFI-2262_8_live-scan-setup
gregorydemay Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 113 additions & 48 deletions rs/ethereum/cketh/test_utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::flow::{
use crate::mock::JsonRpcMethod;
use assert_matches::assert_matches;
use candid::{Decode, Encode, Nat, Principal};
use evm_rpc_types::{InstallArgs, OverrideProvider, RegexSubstitution};
use ic_base_types::PrincipalId;
use ic_cketh_minter::endpoints::events::{Event, EventPayload, GetEventsResult};
use ic_cketh_minter::endpoints::{
Expand Down Expand Up @@ -144,42 +145,18 @@ impl PocketIcHttpQuery for &CkEthSetup {

impl CkEthSetup {
pub fn new(env: Arc<PocketIc>) -> Self {
// Create minter canister first to match canister ID and Ethereum address hardcoded in tests.
let minter_id = env.create_canister();
env.add_cycles(minter_id, u128::MAX);
let ledger_id = env.create_canister();
env.add_cycles(ledger_id, u128::MAX);
let evm_rpc_id = env.create_canister();
env.add_cycles(evm_rpc_id, u128::MAX);

env.install_canister(
ledger_id,
ledger_wasm(),
Encode!(&LedgerArgument::Init(
LedgerInitArgsBuilder::with_symbol_and_name("ckETH", "ckETH")
.with_minting_account(minter_id)
.with_transfer_fee(CKETH_TRANSFER_FEE)
.with_max_memo_length(80)
.with_decimals(18)
.with_feature_flags(ic_icrc1_ledger::FeatureFlags {
icrc2: true,
icrc152: false
})
.build(),
))
.unwrap(),
None,
);
install_evm_rpc(&env, evm_rpc_id);
let minter_id = install_minter(&env, ledger_id, minter_id, evm_rpc_id);
let canisters = create_cketh_canisters(&env);
install_ledger(&env, &canisters);
install_evm_rpc(&env, &canisters, &EvmRpcBackend::Mocked);
install_minter(&env, &canisters, &EvmRpcBackend::Mocked);

let caller = PrincipalId::new_user_test_id(DEFAULT_PRINCIPAL_ID);
Self {
env,
caller,
ledger_id,
minter_id,
evm_rpc_id,
ledger_id: canisters.ledger_id,
minter_id: canisters.minter_id,
evm_rpc_id: canisters.evm_rpc_id,
support_subaccount: false,
}
}
Expand Down Expand Up @@ -752,15 +729,21 @@ pub fn format_ethereum_address_to_eip_55(address: &str) -> String {
}

pub fn new_pocket_ic() -> PocketIc {
PocketIcBuilder::new()
.with_fiduciary_subnet()
pocket_ic_builder()
.with_icp_config(IcpConfig {
canister_execution_rate_limiting: Some(IcpConfigFlag::Disabled),
..Default::default()
})
.build()
}

/// A [`PocketIcBuilder`] with the fiduciary subnet every ckETH fixture needs for the secp256k1
/// `key_1` used by the minter; callers add anything further (e.g. an NNS subnet for
/// [`PocketIc::make_live`]) before `build()`.
fn pocket_ic_builder() -> PocketIcBuilder {
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
PocketIcBuilder::new().with_fiduciary_subnet()
}

fn ledger_wasm() -> Vec<u8> {
let path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.parent()
Expand Down Expand Up @@ -791,36 +774,118 @@ fn evm_rpc_wasm() -> Vec<u8> {
)
}

fn install_minter(
env: &PocketIc,
ledger_id: Principal,
/// The 3 canisters every ckETH fixture creates: the minter, its ckETH ledger and the EVM RPC
/// canister it calls out to.
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
struct CkEthCanisters {
minter_id: Principal,
ledger_id: Principal,
evm_rpc_id: Principal,
) -> Principal {
}

fn create_cketh_canisters(env: &PocketIc) -> CkEthCanisters {
// Create minter canister first to match canister ID and Ethereum address hardcoded in tests.
let minter_id = env.create_canister();
env.add_cycles(minter_id, u128::MAX);
let ledger_id = env.create_canister();
env.add_cycles(ledger_id, u128::MAX);
let evm_rpc_id = env.create_canister();
env.add_cycles(evm_rpc_id, u128::MAX);
CkEthCanisters {
minter_id,
ledger_id,
evm_rpc_id,
}
}

fn install_ledger(env: &PocketIc, canisters: &CkEthCanisters) {
env.install_canister(
canisters.ledger_id,
ledger_wasm(),
Encode!(&LedgerArgument::Init(
LedgerInitArgsBuilder::with_symbol_and_name("ckETH", "ckETH")
.with_minting_account(canisters.minter_id)
.with_transfer_fee(CKETH_TRANSFER_FEE)
.with_max_memo_length(80)
.with_decimals(18)
.with_feature_flags(ic_icrc1_ledger::FeatureFlags {
icrc2: true,
icrc152: false
})
.build(),
))
.unwrap(),
None,
);
}

/// Where the EVM RPC canister's outcalls are answered, and the Ethereum chain state that follows
/// from it.
enum EvmRpcBackend<'a> {
/// Canned JSON-RPC mocks pinned to a historical mainnet snapshot.
Mocked,
/// A live anvil node reached over HTTP at `url`: a fresh chain with no finalized blocks yet.
Anvil(&'a str),
}
Comment thread
gregorydemay marked this conversation as resolved.
Outdated

impl EvmRpcBackend<'_> {
fn install_args(&self) -> InstallArgs {
InstallArgs {
override_provider: match self {
EvmRpcBackend::Mocked => None,
EvmRpcBackend::Anvil(url) => Some(OverrideProvider {
override_url: Some(RegexSubstitution {
pattern: ".*".into(),
replacement: url.to_string(),
}),
}),
},
..Default::default()
}
}

fn ethereum_block_height(&self) -> CandidBlockTag {
match self {
EvmRpcBackend::Mocked => CandidBlockTag::Finalized,
// A fresh anvil chain has no finalized blocks, so track its "latest" head instead.
EvmRpcBackend::Anvil(_) => CandidBlockTag::Latest,
}
}

fn last_scraped_block_number(&self) -> Nat {
match self {
EvmRpcBackend::Mocked => LAST_SCRAPED_BLOCK_NUMBER_AT_INSTALL.into(),
EvmRpcBackend::Anvil(_) => 0_u8.into(),
}
}
Comment thread
gregorydemay marked this conversation as resolved.
}

fn install_minter(env: &PocketIc, canisters: &CkEthCanisters, backend: &EvmRpcBackend) {
let args = MinterInitArgs {
ecdsa_key_name: "key_1".parse().unwrap(),
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
ethereum_network: EthereumNetwork::Mainnet,
ledger_id,
ledger_id: canisters.ledger_id,
next_transaction_nonce: 0_u8.into(),
ethereum_block_height: CandidBlockTag::Finalized,
ethereum_block_height: backend.ethereum_block_height(),
ethereum_contract_address: Some(ETH_HELPER_CONTRACT_ADDRESS.to_string()),
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),
last_scraped_block_number: backend.last_scraped_block_number(),
evm_rpc_id: Some(canisters.evm_rpc_id),
};
let minter_arg = MinterArg::InitArg(args);
env.install_canister(
minter_id,
canisters.minter_id,
minter_wasm(),
Encode!(&minter_arg).unwrap(),
Encode!(&MinterArg::InitArg(args)).unwrap(),
None,
);
minter_id
}

fn install_evm_rpc(env: &PocketIc, evm_rpc_id: Principal) {
let args = evm_rpc_types::InstallArgs::default();
env.install_canister(evm_rpc_id, evm_rpc_wasm(), Encode!(&args).unwrap(), None);
fn install_evm_rpc(env: &PocketIc, canisters: &CkEthCanisters, backend: &EvmRpcBackend) {
env.install_canister(
canisters.evm_rpc_id,
evm_rpc_wasm(),
Encode!(&backend.install_args()).unwrap(),
None,
);
}

fn fail_as_timed_out(env: &PocketIc, request: &CanisterHttpRequest) {
Expand Down
66 changes: 29 additions & 37 deletions rs/ethereum/cketh/test_utils/src/live_scan.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
//! A live [`PocketIc`] harness for the ckERC20 balance scan, driving *real* canister outcalls
//! A live [`pocket_ic::PocketIc`] harness for the ckERC20 balance scan, driving *real* canister outcalls
//! against a local anvil node that the harness owns (see [`crate::anvil`]).
//!
//! Unlike [`crate::ckerc20::CkErc20Setup`] — which runs on `StateMachine` and answers the EVM RPC
//! canister's JSON-RPC outcalls with canned mocks ([`crate::mock::MockJsonRpcProviders`]) — this
//! harness runs PocketIC in *live* mode so the EVM RPC canister makes genuine outcalls through the
//! IC's HTTPS-outcalls feature, and installs it with an `overrideProvider` that rewrites every
//! provider URL to the harness' anvil node (reached over HTTP, mirroring the `evm_rpc_local`
//! configuration of the EVM RPC canister). The minter therefore reads real Ethereum state from
//! anvil, exercising the balance scan end to end: minter → EVM RPC canister → anvil.
//! Unlike [`crate::ckerc20::CkErc20Setup`] — which also runs on PocketIC but answers the EVM RPC
//! canister's JSON-RPC outcalls with canned canister-http mocks ([`crate::mock::MockJsonRpcProviders`])
//! — this harness runs PocketIC in *live* mode so the EVM RPC canister makes genuine outcalls
//! through the IC's HTTPS-outcalls feature, and installs it with an `overrideProvider` that
//! rewrites every provider URL to the harness' anvil node (reached over HTTP, mirroring the
//! `evm_rpc_local` configuration of the EVM RPC canister). The minter therefore reads real
//! Ethereum state from anvil, exercising the balance scan end to end: minter → EVM RPC canister →
//! anvil.
//!
//! Only the minter and the EVM RPC canister are installed. The full ckERC20 feature is activated by
//! pointing the minter's ledger-suite-orchestrator id at a principal this harness controls, so
Expand All @@ -16,7 +17,6 @@
//! minter's state.

use candid::{Decode, Encode, Nat, Principal};
use evm_rpc_types::{InstallArgs, OverrideProvider, RegexSubstitution};
use ic_base_types::PrincipalId;
use ic_cketh_minter::endpoints::{
AddCkErc20Token, DepositErc20Arg, DepositErc20Error, DepositErc20Response, DepositMode,
Expand All @@ -25,7 +25,7 @@ use ic_cketh_minter::lifecycle::upgrade::UpgradeArg;
use ic_cketh_minter::lifecycle::{EthereumNetwork, MinterArg, init::InitArg as MinterInitArgs};
use ic_cketh_minter::numeric::Erc20Value;
use ic_ethereum_types::Address;
use pocket_ic::{CanisterSettings, PocketIc, PocketIcBuilder};
use pocket_ic::{CanisterSettings, PocketIc};
use std::str::FromStr;
use std::time::{Duration, Instant};

Expand All @@ -34,7 +34,7 @@ use crate::anvil::{
};
use crate::{
CKETH_MINIMUM_WITHDRAWAL_AMOUNT, ERC20_HELPER_CONTRACT_ADDRESS, ETH_HELPER_CONTRACT_ADDRESS,
USDC_ERC20_CONTRACT_ADDRESS, evm_rpc_wasm, minter_wasm,
EvmRpcBackend, USDC_ERC20_CONTRACT_ADDRESS, evm_rpc_wasm, minter_wasm, pocket_ic_builder,
};

/// USDT's mainnet address, the second token registered so the scan reads more than one token per
Expand Down Expand Up @@ -68,12 +68,14 @@ pub struct Holding {
pub amount: u128,
}

/// PocketIC's fiduciary subnet holds the secp256k1 test key named `key_1`, the key the minter
/// derives deposit addresses from.
const ECDSA_KEY_NAME: &str = "key_1";

/// A fixed non-anonymous principal used as the canisters' controller and as the minter's stand-in
/// ledger-suite-orchestrator id, so this harness can register supported tokens itself.
///
/// The minter and EVM RPC canister are deliberately controlled by this fixed principal rather than
/// the anonymous default every other ckETH fixture uses: with the anonymous default, the very first
/// HTTPS outcall the minter issues after going live reproducibly crashed the PocketIC replica with a
/// cycle-accounting assertion failure (`Invalid cycle change`). This harness keeps its own
/// canister-creation/controller setup separate from [`crate::CkEthSetup::new`] for that reason.
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
fn controller() -> Principal {
Principal::from_slice(&[0x0a; 10])
}
Expand All @@ -90,11 +92,9 @@ impl CkErc20LiveScanSetup {
/// the EVM RPC canister's outcalls reach anvil for real.
pub fn new_live() -> Self {
let anvil = Anvil::start();

let mut env = PocketIcBuilder::new()
.with_nns_subnet() // make_live requires an NNS subnet.
.with_fiduciary_subnet() // holds the secp256k1 `key_1` used by the minter.
.build();
// `make_live` requires an NNS subnet, on top of the fiduciary subnet every ckETH fixture
// needs for the secp256k1 `key_1` used by the minter.
let mut env = pocket_ic_builder().with_nns_subnet().build();

let settings = CanisterSettings {
controllers: Some(vec![controller()]),
Expand All @@ -107,7 +107,8 @@ impl CkErc20LiveScanSetup {
let evm_rpc_id =
env.create_canister_with_settings(Some(controller()), Some(settings.clone()));
env.add_cycles(evm_rpc_id, u128::from(u64::MAX));
install_evm_rpc(&env, evm_rpc_id, anvil.url());
let backend = EvmRpcBackend::Anvil(anvil.url());
install_evm_rpc(&env, evm_rpc_id, &backend);

let minter_id = env.create_canister_with_settings(Some(controller()), Some(settings));
env.add_cycles(minter_id, u128::from(u64::MAX));
Expand All @@ -117,7 +118,7 @@ impl CkErc20LiveScanSetup {
// if they fired while the outcalls could not be answered.
let _gateway = env.make_live(None);

install_minter(&env, minter_id, ledger_id, evm_rpc_id);
install_minter(&env, minter_id, ledger_id, evm_rpc_id, &backend);
activate_ckerc20(&env, minter_id);
register_supported_tokens(&env, minter_id);

Expand Down Expand Up @@ -253,20 +254,11 @@ fn candidates_in_log(line: &str) -> Option<u64> {
.ok()
}

fn install_evm_rpc(env: &PocketIc, evm_rpc_id: Principal, anvil_url: &str) {
let args = InstallArgs {
override_provider: Some(OverrideProvider {
override_url: Some(RegexSubstitution {
pattern: ".*".into(),
replacement: anvil_url.to_string(),
}),
}),
..Default::default()
};
fn install_evm_rpc(env: &PocketIc, evm_rpc_id: Principal, backend: &EvmRpcBackend) {
env.install_canister(
evm_rpc_id,
evm_rpc_wasm(),
Encode!(&args).unwrap(),
Encode!(&backend.install_args()).unwrap(),
Some(controller()),
);
}
Expand All @@ -276,17 +268,17 @@ fn install_minter(
minter_id: Principal,
ledger_id: Principal,
evm_rpc_id: Principal,
backend: &EvmRpcBackend,
) {
let args = MinterInitArgs {
ethereum_network: EthereumNetwork::Mainnet,
ecdsa_key_name: ECDSA_KEY_NAME.to_string(),
ecdsa_key_name: "key_1".to_string(),
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
ethereum_contract_address: Some(ETH_HELPER_CONTRACT_ADDRESS.to_string()),
ledger_id,
// anvil is a fresh chain with no finalized blocks, so track its "latest" head.
ethereum_block_height: ic_cketh_minter::endpoints::CandidBlockTag::Latest,
ethereum_block_height: backend.ethereum_block_height(),
minimum_withdrawal_amount: Nat::from(CKETH_MINIMUM_WITHDRAWAL_AMOUNT),
next_transaction_nonce: Nat::from(0_u8),
last_scraped_block_number: Nat::from(0_u8),
last_scraped_block_number: backend.last_scraped_block_number(),
evm_rpc_id: Some(evm_rpc_id),
};
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
env.install_canister(
Expand Down
Loading