Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
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
4 changes: 2 additions & 2 deletions rs/ethereum/cketh/minter/tests/deposit_from_cex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use ic_cketh_minter::balance_scan::batcher::{
};
use ic_cketh_minter::numeric::Erc20Value;
use ic_cketh_test_utils::anvil::{Anvil, DEV_ACCOUNT, address_from_hex, deploy_mock_erc20};
use ic_cketh_test_utils::live_scan::{CkErc20LiveScanSetup, Holding, SupportedToken};
use ic_cketh_test_utils::live_scan::{Holding, LiveBalanceScanSetup, SupportedToken};
use ic_ethereum_types::Address;
use std::time::Duration;

Expand Down Expand Up @@ -194,7 +194,7 @@ fn should_flag_only_deposits_at_or_above_the_per_token_minimum() {
const USDC_ABOVE_MINIMUM: u128 = 15_000_000;
const USDT_BELOW_MINIMUM: u128 = 1_000_000;

let setup = CkErc20LiveScanSetup::new_live();
let setup = LiveBalanceScanSetup::new_live();
let deposits = [
(
setup.depositor(1),
Expand Down
313 changes: 251 additions & 62 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 All @@ -31,7 +32,7 @@ use pocket_ic::common::rest::{
CanisterHttpReject, CanisterHttpReply, CanisterHttpRequest, CanisterHttpResponse, IcpConfig,
IcpConfigFlag, MockCanisterHttpResponse, RawEffectivePrincipal, RawMessageId,
};
use pocket_ic::{PocketIc, PocketIcBuilder, RejectResponse};
use pocket_ic::{CanisterSettings, PocketIc, PocketIcBuilder, RejectResponse};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
Expand Down Expand Up @@ -122,7 +123,7 @@ pub struct CkEthSetup {

impl Default for CkEthSetup {
fn default() -> Self {
Self::new(Arc::new(new_pocket_ic()))
Self::builder().build()
}
}

Expand All @@ -144,43 +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);
Self::builder().with_env(env).build()
}

let caller = PrincipalId::new_user_test_id(DEFAULT_PRINCIPAL_ID);
Self {
env,
caller,
ledger_id,
minter_id,
evm_rpc_id,
support_subaccount: false,
/// A builder for [`CkEthSetup`], defaulting to today's mocked-fixture behaviour: a fresh
/// PocketIC instance (fiduciary subnet only), an anonymous controller, and canned JSON-RPC
/// responses. [`live_scan`] extends it for the live balance-scan harness via
/// [`CkEthSetupBuilder::with_live_mode`].
fn builder<'a>() -> CkEthSetupBuilder<'a> {
CkEthSetupBuilder {
env: None,
backend: EthereumBackend::Mocked,
live: false,
}
}

Expand Down Expand Up @@ -747,18 +723,117 @@ impl CkEthSetup {
}
}

struct CkEthSetupBuilder<'a> {
env: Option<Arc<PocketIc>>,
backend: EthereumBackend<'a>,
live: bool,
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
}

impl<'a> CkEthSetupBuilder<'a> {
/// Reuses an existing PocketIC instance instead of building one via [`new_env`], so this
/// fixture can be composed with others that need the same instance (e.g. `CkErc20Setup`'s
/// orchestrator). Not combined with [`Self::with_live_mode`] today: live mode's instance needs
/// the added NNS subnet and to already be live before any canister exists.
fn with_env(mut self, env: Arc<PocketIc>) -> Self {
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
self.env = Some(env);
self
}

fn with_ethereum_backend(mut self, backend: EthereumBackend<'a>) -> Self {
self.backend = backend;
self
}

/// Switches PocketIC to live mode: an NNS subnet in addition to the fiduciary one, going live

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧐 🔵 Nit — this doc enumerates what live mode switches ("an NNS subnet …, going live …, and a fixed non-anonymous controller"), reading as a complete list, but live still drives a fourth axis: skipping the ckETH ledger install. Correctly dropping the cycles item is right (it no longer forks), but the ledger axis should be added here — especially if the L761 comment moves up as suggested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 5d619c3 (same commit as the medium above, since both touch with_live_mode's doc) — added the ledger axis to the list.

/// before any canister exists (see [`new_env`]), a fixed non-anonymous controller (instead of
/// every other fixture's anonymous default) that owns every canister this fixture creates (see
/// [`live_controller`]), and leaving the ckETH ledger uninstalled — the balance scan never
/// calls it, and installing it would need the ledger canister Wasm declared as a Bazel data
/// dependency of the anvil-backed test target, which it is not.
fn with_live_mode(mut self) -> Self {
self.live = true;
self
}

fn build(self) -> CkEthSetup {
debug_assert!(
!(self.live && self.env.is_some()),
"with_env's instance would be used as-is, but self.live would still select the \
controller and skip the ledger install: a live-flagged fixture on a non-live instance"
);
let env_mode = if self.live {
EnvMode::Live
} else {
EnvMode::Mocked
};
let env = self.env.unwrap_or_else(|| Arc::new(new_env(env_mode)));
let controller = self.live.then(live_controller);
let canisters = create_cketh_canisters(&env, controller);
if !self.live {
// Live mode leaves the ckETH ledger uninstalled; see with_live_mode's doc for why.
install_ledger(&env, &canisters);
}
install_evm_rpc(&env, &canisters, &self.backend);
install_minter(&env, &canisters, &self.backend);

CkEthSetup {
env,
caller: PrincipalId::new_user_test_id(DEFAULT_PRINCIPAL_ID),
ledger_id: canisters.ledger_id,
minter_id: canisters.minter_id,
evm_rpc_id: canisters.evm_rpc_id,
support_subaccount: false,
}
}
}

/// A fixed non-anonymous principal used as the live harness's canisters' controller and as the
/// minter's stand-in ledger-suite-orchestrator id, so [`live_scan`] can both register supported
/// tokens itself (calling `add_ckerc20_token` as the orchestrator) and fetch the minter's canister
/// logs (controller-only by default) as that same principal.
fn live_controller() -> Principal {
Principal::from_slice(&[0x0a; 10])
}

#[derive(Clone, Copy)]
enum EnvMode {
Mocked,
Live,
}

/// Builds the PocketIC instance for [`CkEthSetupBuilder::build`]: the fiduciary subnet every
/// ckETH fixture needs for the secp256k1 `key_1` used by the minter, plus — in [`EnvMode::Live`] —
/// an NNS subnet (required by [`PocketIc::make_live`]) and going live immediately, before any
/// canister of this fixture exists to schedule a timer whose outcall could stall waiting for an
/// answer.
fn new_env(mode: EnvMode) -> PocketIc {
let mut builder = pocket_ic_builder().with_icp_config(IcpConfig {
canister_execution_rate_limiting: Some(IcpConfigFlag::Disabled),
..Default::default()
});
if let EnvMode::Live = mode {
builder = builder.with_nns_subnet();
}
let mut env = builder.build();
if let EnvMode::Live = mode {
env.make_live(None);
}
env
}

pub fn format_ethereum_address_to_eip_55(address: &str) -> String {
Address::from_str(address).unwrap().to_string()
}

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

/// 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> {
Expand Down Expand Up @@ -791,36 +866,150 @@ fn evm_rpc_wasm() -> Vec<u8> {
)
}

fn install_minter(
env: &PocketIc,
ledger_id: Principal,
/// The minter, its ckETH ledger and the EVM RPC canister it calls out to, plus the sender to
/// install/upgrade them as (`None`: every other fixture's anonymous default; `Some`: the live
/// harness' [`live_controller`]). Built by [`create_cketh_canisters`] and installed by
/// [`install_ledger`]/[`install_minter`]/[`install_evm_rpc`], used by
/// [`CkEthSetupBuilder::build`] for both modes.
struct CkEthCanisters {
minter_id: Principal,
ledger_id: Principal,
evm_rpc_id: Principal,
) -> Principal {
controller: Option<Principal>,
}

fn create_canister(env: &PocketIc, controller: Option<Principal>) -> Principal {
match controller {
None => env.create_canister(),
Some(controller) => env.create_canister_with_settings(
Some(controller),
Some(CanisterSettings {
controllers: Some(vec![controller]),
..Default::default()
}),
),
}
}

/// Cycles every canister this fixture creates is funded with. `u128::MAX` — the natural "as much as
/// possible" choice — reproducibly crashes the live harness' PocketIC replica with a
/// cycle-accounting assertion failure (`Invalid cycle change`) on the minter's first HTTPS outcall:
/// a canister already at the saturating `Cycles` balance ceiling cannot observe any further
/// addition. This amount leaves headroom below that ceiling instead.
const CANISTER_CYCLES: u128 = u64::MAX as u128;

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

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(),
canisters.controller,
);
}

/// The Ethereum chain under test: which JSON-RPC endpoint the EVM RPC canister's outcalls reach,
/// and the corresponding minter init/upgrade assumptions about that chain's state (the block
/// height to track, and where its log-scraping cursor starts).
enum EthereumBackend<'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),
}

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

fn ethereum_block_height(&self) -> CandidBlockTag {
match self {
// The mocked responses replay a historical mainnet snapshot, long since finalized.
EthereumBackend::Mocked => CandidBlockTag::Finalized,
// A fresh anvil chain has no finalized blocks, so track its "latest" head instead.
EthereumBackend::Anvil(_) => CandidBlockTag::Latest,
}
}

fn last_scraped_block_number(&self) -> Nat {
match self {
// The block the mocked JSON-RPC responses are canned to scrape logs from onward.
EthereumBackend::Mocked => LAST_SCRAPED_BLOCK_NUMBER_AT_INSTALL.into(),
EthereumBackend::Anvil(_) => 0_u8.into(),
}
}
Comment thread
gregorydemay marked this conversation as resolved.
}

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

fn install_minter(env: &PocketIc, canisters: &CkEthCanisters, backend: &EthereumBackend) {
let args = MinterInitArgs {
ecdsa_key_name: "key_1".parse().unwrap(),
ecdsa_key_name: ECDSA_KEY_NAME.to_string(),
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(),
None,
Encode!(&MinterArg::InitArg(args)).unwrap(),
canisters.controller,
);
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: &EthereumBackend) {
env.install_canister(
canisters.evm_rpc_id,
evm_rpc_wasm(),
Encode!(&backend.install_args()).unwrap(),
canisters.controller,
);
}

fn fail_as_timed_out(env: &PocketIc, request: &CanisterHttpRequest) {
Expand Down
Loading
Loading