diff --git a/Cargo.lock b/Cargo.lock
index 09292cdba495..3de39c22ef4b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7506,6 +7506,8 @@ dependencies = [
"ic-types-cycles",
"icrc-ledger-types",
"num-traits",
+ "pocket-ic",
+ "reqwest",
"serde",
"serde_bytes",
"serde_json",
diff --git a/rs/ethereum/cketh/minter/BUILD.bazel b/rs/ethereum/cketh/minter/BUILD.bazel
index e5879bb72621..d0f9ad8e4ce3 100644
--- a/rs/ethereum/cketh/minter/BUILD.bazel
+++ b/rs/ethereum/cketh/minter/BUILD.bazel
@@ -285,28 +285,35 @@ rust_test(
)
rust_test(
- name = "deposit_from_cex_test",
- size = "small",
+ name = "deposit_from_cex",
+ # The end-to-end balance-scan test runs a live PocketIC and waits (wall-clock) for the minter's
+ # periodic scan, so it needs a larger timeout than the pure-anvil tests.
+ size = "medium",
srcs = ["tests/deposit_from_cex.rs"],
data = [
"//:anvil",
"//:solc",
# Reuses the mock ERC-20 source compiled at test time by the vendored solc.
"tests/deposit_from_cex_demo/MockUSDT.sol",
+ # End-to-end balance scan on a live PocketIC + local anvil node.
+ ":cketh_minter_debug.wasm.gz",
+ "//rs/pocket_ic_server:pocket-ic-server",
+ "@evm_rpc.wasm.gz//file",
],
env = {
"ANVIL_BIN": "$(rootpath //:anvil)",
"MOCKUSDT_SOL": "$(rootpath tests/deposit_from_cex_demo/MockUSDT.sol)",
"SOLC_BIN": "$(rootpath //:solc)",
+ "CARGO_MANIFEST_DIR": "rs/ethereum/cketh/minter",
+ "CKETH_MINTER_WASM_PATH": "$(rootpath :cketh_minter_debug.wasm.gz)",
+ "EVM_RPC_CANISTER_WASM_PATH": "$(rootpath @evm_rpc.wasm.gz//file)",
+ "POCKET_IC_BIN": "$(rootpath //rs/pocket_ic_server:pocket-ic-server)",
},
deps = [
# Keep sorted.
":minter",
"//packages/ic-ethereum-types",
- "@crate_index//:ethers-core",
- "@crate_index//:hex",
- "@crate_index//:reqwest",
- "@crate_index//:serde_json",
+ "//rs/ethereum/cketh/test_utils",
],
)
diff --git a/rs/ethereum/cketh/minter/tests/deposit_from_cex.rs b/rs/ethereum/cketh/minter/tests/deposit_from_cex.rs
index a724b163c45f..b242082788c8 100644
--- a/rs/ethereum/cketh/minter/tests/deposit_from_cex.rs
+++ b/rs/ethereum/cketh/minter/tests/deposit_from_cex.rs
@@ -13,25 +13,22 @@
//! non-contract "token" reverts the whole call rather than reporting a zero
//! balance.
//!
-//! Runs the `anvil` and `solc` binaries vendored via Bazel (`ANVIL_BIN`,
-//! `SOLC_BIN`); see BUILD.bazel.
+//! A final test drives the whole balance scan end to end through a live
+//! PocketIC and the real EVM RPC canister (see
+//! [`ic_cketh_test_utils::live_scan`]).
+//!
+//! The anvil node client and its ABI/solc helpers live in
+//! [`ic_cketh_test_utils::anvil`]; `anvil` and `solc` are vendored via Bazel
+//! (`ANVIL_BIN`, `SOLC_BIN`); see BUILD.bazel.
-use ethers_core::abi::{ParamType, Token};
-use ethers_core::types::{Address as EthAddress, U256};
-use ethers_core::utils::keccak256;
use ic_cketh_minter::balance_scan::batcher::{
BalanceOfCall, decode_balance_batch, encode_balance_batch,
};
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_ethereum_types::Address;
-use serde_json::Value;
-use std::process::{Child, Command, Stdio};
-use std::time::{Duration, Instant};
-
-/// Anvil's first dev account: unlocked and pre-funded, so transfers can go
-/// through `eth_sendTransaction` without any local signing.
-const DEV_ACCOUNT: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
-const TOKEN_SUPPLY: u128 = 1_000_000_000;
+use std::time::Duration;
#[test]
fn should_read_erc20_balances_across_tokens_and_holders() {
@@ -46,10 +43,10 @@ fn should_read_erc20_balances_across_tokens_and_holders() {
let h2 = Address::new([0x22; 20]);
let h3 = Address::new([0x33; 20]); // never funded -> balance 0
- fund(&anvil, &token_a, &dev, &h1, 100);
- fund(&anvil, &token_a, &dev, &h2, 250);
- fund(&anvil, &token_b, &dev, &h1, 7);
- fund(&anvil, &token_b, &dev, &h3, 999);
+ anvil.fund(&token_a, &dev, &h1, 100);
+ anvil.fund(&token_a, &dev, &h2, 250);
+ anvil.fund(&token_b, &dev, &h1, 7);
+ anvil.fund(&token_b, &dev, &h3, 999);
let calls = vec![
BalanceOfCall {
@@ -116,7 +113,7 @@ fn should_read_many_balances_in_a_single_call() {
const N: u64 = 32;
let holders: Vec
= (0..N).map(holder_at).collect();
for (i, holder) in holders.iter().enumerate() {
- fund(&anvil, &token, &dev, holder, (i as u128 + 1) * 1_000);
+ anvil.fund(&token, &dev, holder, (i as u128 + 1) * 1_000);
}
let calls: Vec = holders
@@ -144,7 +141,7 @@ fn should_revert_the_whole_call_when_a_token_is_not_a_contract() {
let dev = address_from_hex(DEV_ACCOUNT);
let token = deploy_mock_erc20(&anvil, &dev);
let holder = Address::new([0x11; 20]);
- fund(&anvil, &token, &dev, &holder, 500);
+ anvil.fund(&token, &dev, &holder, 500);
// A "token" with no code: STATICCALL succeeds with empty return data, which
// is not the 32 bytes the batcher requires, so it reverts the whole call
@@ -180,266 +177,76 @@ fn should_revert_the_whole_call_when_a_token_is_not_a_contract() {
);
}
-fn holder_at(index: u64) -> Address {
- let mut bytes = [0_u8; 20];
- bytes[..8].copy_from_slice(&index.to_be_bytes());
- // Offset so no holder collides with the deployer or a low reserved address.
- bytes[0] = 0xd0;
- Address::new(bytes)
-}
-
-/// Deploys `MockUSDT` with the whole supply minted to `holder`.
-fn deploy_mock_erc20(anvil: &Anvil, holder: &Address) -> Address {
- let code = deploy_code(
- &compile("MOCKUSDT_SOL", "MockUSDT"),
- &[address_token(holder), uint_token(TOKEN_SUPPLY)],
- );
- anvil.deploy(holder, &code)
-}
-
-/// Transfers `amount` of `token` from `dev` to `holder`.
-fn fund(anvil: &Anvil, token: &Address, dev: &Address, holder: &Address, amount: u128) {
- let tx = anvil.send_transaction(
- dev,
- Some(token),
- &call(
- "transfer(address,uint256)",
- &[address_token(holder), uint_token(amount)],
+/// End-to-end balance scan against a real EVM: a live PocketIC runs the minter and the *real* EVM
+/// RPC canister (configured to route every provider to the harness' anvil node), so the minter's
+/// periodic balance scan makes genuine outcalls through the IC's HTTPS-outcalls feature — reaching
+/// anvil over HTTP — and reads real ERC-20 balances from it.
+///
+/// Three independent depositors each fund a single token — 20 USDT, 15 USDC and 1 USDT — so the
+/// scan reads several addresses and tokens and must apply the per-token minimum to each. Only the
+/// two at-or-above-minimum deposits are flagged as candidates; the 1 USDT deposit is scanned but,
+/// being below the ~$10 minimum, is not.
+#[test]
+fn should_flag_only_deposits_at_or_above_the_per_token_minimum() {
+ const DEPOSIT_SUBACCOUNT: [u8; 32] = [42; 32];
+ // 6-decimal amounts; ckUSDC and ckUSDT share a 10_000_000 (~$10) candidate minimum.
+ const USDT_ABOVE_MINIMUM: u128 = 20_000_000;
+ const USDC_ABOVE_MINIMUM: u128 = 15_000_000;
+ const USDT_BELOW_MINIMUM: u128 = 1_000_000;
+
+ let setup = CkErc20LiveScanSetup::new_live();
+ let deposits = [
+ (
+ setup.depositor(1),
+ SupportedToken::CkUsdt,
+ USDT_ABOVE_MINIMUM,
),
- );
- assert!(
- status_ok(&anvil.await_receipt(&tx)),
- "ERC-20 transfer failed"
- );
-}
-
-// ---------------------------------------------------------------------------
-// ABI encoding / decoding via ethers-core (ethabi).
-// ---------------------------------------------------------------------------
-
-/// A function call: the 4-byte selector followed by the ABI-encoded arguments.
-fn call(signature: &str, tokens: &[Token]) -> Vec {
- let selector = &keccak256(signature.as_bytes())[..4];
- [selector, ðers_core::abi::encode(tokens)].concat()
-}
-
-fn address_token(address: &Address) -> Token {
- Token::Address(EthAddress::from_slice(address.as_ref()))
-}
-
-fn uint_token(value: u128) -> Token {
- Token::Uint(U256::from(value))
-}
-
-fn decode_uint(data: &[u8]) -> u128 {
- ethers_core::abi::decode(&[ParamType::Uint(256)], data)
- .expect("ABI decode failed")
- .pop()
- .unwrap()
- .into_uint()
- .unwrap()
- .as_u128()
-}
-
-fn deploy_code(bytecode: &[u8], constructor_args: &[Token]) -> Vec {
- [bytecode, ðers_core::abi::encode(constructor_args)].concat()
-}
+ (
+ setup.depositor(2),
+ SupportedToken::CkUsdc,
+ USDC_ABOVE_MINIMUM,
+ ),
+ (
+ setup.depositor(3),
+ SupportedToken::CkUsdt,
+ USDT_BELOW_MINIMUM,
+ ),
+ ];
-/// Compiles `contract` from the Solidity source at env var `source_var` using
-/// the vendored `solc`, returning its creation bytecode.
-fn compile(source_var: &str, contract: &str) -> Vec {
- let solc = std::env::var("SOLC_BIN").expect("SOLC_BIN not set by Bazel");
- let source = std::env::var(source_var).expect("contract source env var not set by Bazel");
- let output = Command::new(&solc)
- .args([
- "--combined-json",
- "bin",
- "--optimize",
- "--optimize-runs",
- "200",
- &source,
- ])
- .output()
- .unwrap_or_else(|e| panic!("failed to run solc at {solc}: {e}"));
- assert!(
- output.status.success(),
- "solc failed for {source}:\n{}",
- String::from_utf8_lossy(&output.stderr)
- );
- let compiled: Value = serde_json::from_slice(&output.stdout).unwrap();
- let (_, artifact) = compiled["contracts"]
- .as_object()
- .unwrap()
+ let holdings: Vec = deposits
.iter()
- .find(|(key, _)| key.ends_with(&format!(":{contract}")))
- .unwrap_or_else(|| panic!("solc did not produce contract {contract} from {source}"));
- hex::decode(artifact["bin"].as_str().unwrap()).unwrap()
-}
-
-// ---------------------------------------------------------------------------
-// Local anvil node + JSON-RPC transport.
-// ---------------------------------------------------------------------------
-
-struct Anvil {
- child: Child,
- url: String,
-}
-
-impl Anvil {
- fn start() -> Self {
- let bin = std::env::var("ANVIL_BIN").expect("ANVIL_BIN not set by Bazel");
- let port = {
- let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
- listener.local_addr().unwrap().port()
- };
- let mut child = Command::new(&bin)
- .arg("--host")
- .arg("127.0.0.1")
- .arg("--port")
- .arg(port.to_string())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .spawn()
- .unwrap_or_else(|e| panic!("failed to spawn anvil at {bin}: {e}"));
- let url = format!("http://127.0.0.1:{port}");
- wait_until_ready(&mut child, &bin, &url);
- Self { child, url }
- }
-
- /// Sends a JSON-RPC request, returning the raw `result`/`error` body so the
- /// caller can decide whether an error is a failure.
- fn rpc_result(&self, method: &str, params: Value) -> Result {
- let body: Value = reqwest::blocking::Client::new()
- .post(&self.url)
- .json(
- &serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}),
- )
- .send()
- .unwrap()
- .json()
- .unwrap();
- match body.get("error") {
- Some(error) if !error.is_null() => Err(error.to_string()),
- _ => Ok(body["result"].clone()),
- }
- }
-
- fn rpc(&self, method: &str, params: Value) -> Value {
- self.rpc_result(method, params)
- .unwrap_or_else(|e| panic!("RPC {method} failed: {e}"))
- }
-
- fn code(&self, address: &Address) -> Vec {
- from_hex(
- self.rpc(
- "eth_getCode",
- serde_json::json!([to_hex(address.as_ref()), "latest"]),
- )
- .as_str()
- .unwrap(),
- )
- }
-
- /// A create-style `eth_call` (no `to`): anvil runs `data` as init code and
- /// returns whatever it `RETURN`s, exactly as the minter invokes the batcher.
- fn eth_call_create(&self, from: &Address, data: &[u8]) -> Result, String> {
- self.rpc_result(
- "eth_call",
- serde_json::json!([{"from": to_hex(from.as_ref()), "input": to_hex(data)}, "latest"]),
- )
- .map(|value| from_hex(value.as_str().unwrap()))
- }
-
- fn erc20_balance(&self, token: &Address, holder: &Address) -> Erc20Value {
- let out = from_hex(
- self.rpc(
- "eth_call",
- serde_json::json!([
- {"to": to_hex(token.as_ref()),
- "input": to_hex(&call("balanceOf(address)", &[address_token(holder)]))},
- "latest"
- ]),
- )
- .as_str()
- .unwrap(),
+ .map(|&(depositor, token, amount)| Holding {
+ deposit: setup.register_deposit_address(depositor, DEPOSIT_SUBACCOUNT),
+ token,
+ amount,
+ })
+ .collect();
+ setup.credit_deposits(&holdings);
+
+ // deposit_erc20 reports each address as scanned; a failed batch would never advance any of them.
+ for &(depositor, _, _) in &deposits {
+ let progress = setup.await_scan(depositor, DEPOSIT_SUBACCOUNT, Duration::from_secs(180));
+ assert!(
+ progress.scan_count >= 1,
+ "each address should report a scan"
+ );
+ assert!(
+ progress.last_scanned_block.is_some(),
+ "a scanned address should report the block it was scanned at"
);
- Erc20Value::from(decode_uint(&out))
- }
-
- fn send_transaction(&self, from: &Address, to: Option<&Address>, data: &[u8]) -> String {
- let mut tx = serde_json::json!({"from": to_hex(from.as_ref()), "input": to_hex(data)});
- if let Some(to) = to {
- tx["to"] = serde_json::json!(to_hex(to.as_ref()));
- }
- self.rpc("eth_sendTransaction", serde_json::json!([tx]))
- .as_str()
- .unwrap()
- .to_string()
- }
-
- fn deploy(&self, from: &Address, code: &[u8]) -> Address {
- let hash = self.send_transaction(from, None, code);
- let receipt = self.await_receipt(&hash);
- assert!(status_ok(&receipt), "deployment reverted");
- address_from_hex(receipt["contractAddress"].as_str().unwrap())
- }
-
- fn await_receipt(&self, tx_hash: &str) -> Value {
- let deadline = Instant::now() + Duration::from_secs(10);
- while Instant::now() < deadline {
- let receipt = self.rpc("eth_getTransactionReceipt", serde_json::json!([tx_hash]));
- if !receipt.is_null() {
- return receipt;
- }
- std::thread::sleep(Duration::from_millis(50));
- }
- panic!("no receipt for {tx_hash} within 10s");
- }
-}
-
-impl Drop for Anvil {
- fn drop(&mut self) {
- let _ = self.child.kill();
- let _ = self.child.wait();
- }
-}
-
-fn wait_until_ready(child: &mut Child, bin: &str, url: &str) {
- let deadline = Instant::now() + Duration::from_secs(30);
- while Instant::now() < deadline {
- if let Some(status) = child.try_wait().expect("failed to poll anvil") {
- panic!("anvil ({bin}) exited early with {status} before serving {url}");
- }
- let ready = reqwest::blocking::Client::new()
- .post(url)
- .json(&serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": []}))
- .send()
- .map(|r| r.status().is_success())
- .unwrap_or(false);
- if ready {
- return;
- }
- std::thread::sleep(Duration::from_millis(100));
}
- panic!("anvil did not become ready within 30s at {url}");
-}
-
-// ---------------------------------------------------------------------------
-// Small hex / receipt helpers.
-// ---------------------------------------------------------------------------
-fn status_ok(receipt: &Value) -> bool {
- receipt["status"] == "0x1"
-}
-
-fn to_hex(bytes: &[u8]) -> String {
- format!("0x{}", hex::encode(bytes))
-}
-
-fn from_hex(hex_str: &str) -> Vec {
- hex::decode(hex_str.trim_start_matches("0x")).unwrap()
+ assert_eq!(
+ setup.balance_scan_candidates(),
+ 2,
+ "only the 20 USDT and 15 USDC deposits clear the per-token minimum; the 1 USDT does not"
+ );
}
-fn address_from_hex(hex_str: &str) -> Address {
- Address::new(from_hex(hex_str).try_into().unwrap())
+fn holder_at(index: u64) -> Address {
+ let mut bytes = [0_u8; 20];
+ bytes[..8].copy_from_slice(&index.to_be_bytes());
+ // Offset so no holder collides with the deployer or a low reserved address.
+ bytes[0] = 0xd0;
+ Address::new(bytes)
}
diff --git a/rs/ethereum/cketh/test_utils/BUILD.bazel b/rs/ethereum/cketh/test_utils/BUILD.bazel
index aed0c3de0a6d..1fc4dcd05f3f 100644
--- a/rs/ethereum/cketh/test_utils/BUILD.bazel
+++ b/rs/ethereum/cketh/test_utils/BUILD.bazel
@@ -21,6 +21,7 @@ rust_library(
"//packages/ic-http-types",
"//packages/ic-metrics-assert",
"//packages/icrc-ledger-types:icrc_ledger_types_storable",
+ "//packages/pocket-ic",
"//rs/ethereum/cketh/minter",
"//rs/ethereum/ledger-suite-orchestrator:ledger_suite_orchestrator",
"//rs/ethereum/ledger-suite-orchestrator/test_utils",
@@ -38,6 +39,7 @@ rust_library(
"@crate_index//:hex",
"@crate_index//:ic-cdk",
"@crate_index//:num-traits",
+ "@crate_index//:reqwest",
"@crate_index//:serde",
"@crate_index//:serde_bytes",
"@crate_index//:serde_json",
diff --git a/rs/ethereum/cketh/test_utils/Cargo.toml b/rs/ethereum/cketh/test_utils/Cargo.toml
index 377647797377..f47ed2f44ae4 100644
--- a/rs/ethereum/cketh/test_utils/Cargo.toml
+++ b/rs/ethereum/cketh/test_utils/Cargo.toml
@@ -29,6 +29,8 @@ ic-types-cycles = { path = "../../../types/cycles" }
ic-types = { path = "../../../types/types" }
icrc-ledger-types = { path = "../../../../packages/icrc-ledger-types" }
num-traits = { workspace = true }
+pocket-ic = { path = "../../../../packages/pocket-ic" }
+reqwest = { workspace = true }
serde = { workspace = true }
serde_bytes = { workspace = true }
serde_json = { workspace = true }
diff --git a/rs/ethereum/cketh/test_utils/src/anvil.rs b/rs/ethereum/cketh/test_utils/src/anvil.rs
new file mode 100644
index 000000000000..fc79e1c1db2d
--- /dev/null
+++ b/rs/ethereum/cketh/test_utils/src/anvil.rs
@@ -0,0 +1,328 @@
+//! A local [`Anvil`] node (foundry) with a small JSON-RPC client and the ABI/solc helpers used to
+//! drive it. Backs both the standalone batcher tests in `deposit_from_cex.rs` (which run against
+//! anvil with no IC) and the live balance-scan harness in [`crate::live_scan`].
+//!
+//! Runs the `anvil` and `solc` binaries vendored via Bazel (`ANVIL_BIN`, `SOLC_BIN`).
+
+use ethers_core::abi::{ParamType, Token};
+use ethers_core::types::{Address as EthAddress, U256};
+use ethers_core::utils::keccak256;
+use ic_cketh_minter::numeric::Erc20Value;
+use ic_ethereum_types::Address;
+use serde_json::Value;
+use std::process::{Child, Command, Stdio};
+use std::time::{Duration, Instant};
+
+/// Anvil's first dev account: unlocked and pre-funded, so transfers and deployments go through
+/// `eth_sendTransaction` without any local signing.
+pub const DEV_ACCOUNT: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
+
+/// The whole supply minted to the deployer when deploying a [`deploy_mock_erc20`] token.
+const TOKEN_SUPPLY: u128 = 1_000_000_000;
+
+/// Per-request timeout for the anvil JSON-RPC client. Without it a stuck node would hang a
+/// `send()` indefinitely, defeating [`wait_until_ready`]'s deadline and, ultimately, the bazel
+/// test timeout; with it a wedged connection fails fast and the caller can retry or panic.
+const RPC_TIMEOUT: Duration = Duration::from_secs(10);
+
+fn rpc_client() -> reqwest::blocking::Client {
+ reqwest::blocking::Client::builder()
+ .timeout(RPC_TIMEOUT)
+ .build()
+ .expect("failed to build the anvil RPC client")
+}
+
+pub struct Anvil {
+ child: Child,
+ url: String,
+ /// Built once and reused across RPCs, so calls share a connection pool instead of paying for a
+ /// fresh client (and TCP connection) each time.
+ client: reqwest::blocking::Client,
+}
+
+impl Anvil {
+ pub fn start() -> Self {
+ let bin = std::env::var("ANVIL_BIN").expect("ANVIL_BIN not set by Bazel");
+ let port = {
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ listener.local_addr().unwrap().port()
+ };
+ let mut child = Command::new(&bin)
+ .arg("--host")
+ .arg("127.0.0.1")
+ .arg("--port")
+ .arg(port.to_string())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .unwrap_or_else(|e| panic!("failed to spawn anvil at {bin}: {e}"));
+ let url = format!("http://127.0.0.1:{port}");
+ let client = rpc_client();
+ wait_until_ready(&mut child, &bin, &url, &client);
+ Self { child, url, client }
+ }
+
+ pub(crate) fn url(&self) -> &str {
+ &self.url
+ }
+
+ /// Sends a JSON-RPC request, returning the raw `result`/`error` body so the caller can decide
+ /// whether an error is a failure. Transport and decode failures — including a `RPC_TIMEOUT`
+ /// timeout — are returned as `Err` (tagged with the method) rather than panicking, so callers
+ /// can distinguish them.
+ fn rpc_result(&self, method: &str, params: Value) -> Result {
+ let response = self
+ .client
+ .post(&self.url)
+ .json(
+ &serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}),
+ )
+ .send()
+ .map_err(|e| format!("RPC {method} request failed: {e}"))?;
+ let body: Value = response
+ .json()
+ .map_err(|e| format!("RPC {method} returned an undecodable body: {e}"))?;
+ match body.get("error") {
+ Some(error) if !error.is_null() => Err(error.to_string()),
+ _ => Ok(body["result"].clone()),
+ }
+ }
+
+ fn rpc(&self, method: &str, params: Value) -> Value {
+ self.rpc_result(method, params)
+ .unwrap_or_else(|e| panic!("RPC {method} failed: {e}"))
+ }
+
+ pub fn code(&self, address: &Address) -> Vec {
+ from_hex(
+ self.rpc(
+ "eth_getCode",
+ serde_json::json!([to_hex(address.as_ref()), "latest"]),
+ )
+ .as_str()
+ .unwrap(),
+ )
+ }
+
+ /// Places `code` as the runtime bytecode at `address` (foundry's `anvil_setCode` cheatcode).
+ pub(crate) fn set_code(&self, address: &Address, code: &[u8]) {
+ self.rpc(
+ "anvil_setCode",
+ serde_json::json!([to_hex(address.as_ref()), to_hex(code)]),
+ );
+ }
+
+ /// Writes a 32-byte storage `value` at `slot` of `address` (foundry's `anvil_setStorageAt`).
+ pub(crate) fn set_storage_at(&self, address: &Address, slot: &[u8; 32], value: &[u8; 32]) {
+ self.rpc(
+ "anvil_setStorageAt",
+ serde_json::json!([to_hex(address.as_ref()), to_hex(slot), to_hex(value)]),
+ );
+ }
+
+ /// A create-style `eth_call` (no `to`): anvil runs `data` as init code and returns whatever it
+ /// `RETURN`s, exactly as the minter invokes the batcher.
+ pub fn eth_call_create(&self, from: &Address, data: &[u8]) -> Result, String> {
+ self.rpc_result(
+ "eth_call",
+ serde_json::json!([{"from": to_hex(from.as_ref()), "input": to_hex(data)}, "latest"]),
+ )
+ .map(|value| from_hex(value.as_str().unwrap()))
+ }
+
+ pub fn erc20_balance(&self, token: &Address, holder: &Address) -> Erc20Value {
+ let out = from_hex(
+ self.rpc(
+ "eth_call",
+ serde_json::json!([
+ {"to": to_hex(token.as_ref()),
+ "input": to_hex(&call("balanceOf(address)", &[address_token(holder)]))},
+ "latest"
+ ]),
+ )
+ .as_str()
+ .unwrap(),
+ );
+ Erc20Value::from(decode_uint(&out))
+ }
+
+ /// Transfers `amount` of `token` from `from` to `to` via a plain ERC-20 `transfer`.
+ pub fn fund(&self, token: &Address, from: &Address, to: &Address, amount: u128) {
+ let tx = self.send_transaction(
+ from,
+ Some(token),
+ &call(
+ "transfer(address,uint256)",
+ &[address_token(to), uint_token(amount)],
+ ),
+ );
+ assert!(
+ status_ok(&self.await_receipt(&tx)),
+ "ERC-20 transfer failed"
+ );
+ }
+
+ fn send_transaction(&self, from: &Address, to: Option<&Address>, data: &[u8]) -> String {
+ let mut tx = serde_json::json!({"from": to_hex(from.as_ref()), "input": to_hex(data)});
+ if let Some(to) = to {
+ tx["to"] = serde_json::json!(to_hex(to.as_ref()));
+ }
+ self.rpc("eth_sendTransaction", serde_json::json!([tx]))
+ .as_str()
+ .unwrap()
+ .to_string()
+ }
+
+ pub(crate) fn deploy(&self, from: &Address, code: &[u8]) -> Address {
+ let hash = self.send_transaction(from, None, code);
+ let receipt = self.await_receipt(&hash);
+ assert!(status_ok(&receipt), "deployment reverted");
+ address_from_hex(receipt["contractAddress"].as_str().unwrap())
+ }
+
+ fn await_receipt(&self, tx_hash: &str) -> Value {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ while Instant::now() < deadline {
+ let receipt = self.rpc("eth_getTransactionReceipt", serde_json::json!([tx_hash]));
+ if !receipt.is_null() {
+ return receipt;
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ panic!("no receipt for {tx_hash} within 10s");
+ }
+}
+
+impl Drop for Anvil {
+ fn drop(&mut self) {
+ let _ = self.child.kill();
+ let _ = self.child.wait();
+ }
+}
+
+fn wait_until_ready(child: &mut Child, bin: &str, url: &str, client: &reqwest::blocking::Client) {
+ let deadline = Instant::now() + Duration::from_secs(30);
+ while Instant::now() < deadline {
+ if let Some(status) = child.try_wait().expect("failed to poll anvil") {
+ panic!("anvil ({bin}) exited early with {status} before serving {url}");
+ }
+ let ready = client
+ .post(url)
+ .json(&serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": []}))
+ .send()
+ .map(|r| r.status().is_success())
+ .unwrap_or(false);
+ if ready {
+ return;
+ }
+ std::thread::sleep(Duration::from_millis(100));
+ }
+ panic!("anvil did not become ready within 30s at {url}");
+}
+
+/// Deploys `MockUSDT` with the whole supply minted to `holder`, returning its address.
+pub fn deploy_mock_erc20(anvil: &Anvil, holder: &Address) -> Address {
+ let code = deploy_code(
+ &compile("MOCKUSDT_SOL", "MockUSDT"),
+ &[address_token(holder), uint_token(TOKEN_SUPPLY)],
+ );
+ anvil.deploy(holder, &code)
+}
+
+/// The storage slot of `balanceOf[holder]` for a Solidity `mapping(address => uint256)` declared at
+/// slot 0 (as in `MockUSDT`): `keccak256(pad32(holder) ‖ pad32(0))`.
+pub(crate) fn erc20_balance_slot(holder: &Address) -> [u8; 32] {
+ let mut key = [0_u8; 64];
+ key[12..32].copy_from_slice(holder.as_ref());
+ keccak256(key)
+}
+
+/// A `u128` as a big-endian 32-byte EVM word.
+pub(crate) fn u256_be(value: u128) -> [u8; 32] {
+ let mut word = [0_u8; 32];
+ word[16..].copy_from_slice(&value.to_be_bytes());
+ word
+}
+
+// ---------------------------------------------------------------------------
+// ABI encoding / decoding via ethers-core (ethabi).
+// ---------------------------------------------------------------------------
+
+/// A function call: the 4-byte selector followed by the ABI-encoded arguments.
+fn call(signature: &str, tokens: &[Token]) -> Vec {
+ let selector = &keccak256(signature.as_bytes())[..4];
+ [selector, ðers_core::abi::encode(tokens)].concat()
+}
+
+fn address_token(address: &Address) -> Token {
+ Token::Address(EthAddress::from_slice(address.as_ref()))
+}
+
+fn uint_token(value: u128) -> Token {
+ Token::Uint(U256::from(value))
+}
+
+fn decode_uint(data: &[u8]) -> u128 {
+ ethers_core::abi::decode(&[ParamType::Uint(256)], data)
+ .expect("ABI decode failed")
+ .pop()
+ .unwrap()
+ .into_uint()
+ .unwrap()
+ .as_u128()
+}
+
+fn deploy_code(bytecode: &[u8], constructor_args: &[Token]) -> Vec {
+ [bytecode, ðers_core::abi::encode(constructor_args)].concat()
+}
+
+/// Compiles `contract` from the Solidity source at env var `source_var` using the vendored `solc`,
+/// returning its creation bytecode.
+fn compile(source_var: &str, contract: &str) -> Vec {
+ let solc = std::env::var("SOLC_BIN").expect("SOLC_BIN not set by Bazel");
+ let source = std::env::var(source_var).expect("contract source env var not set by Bazel");
+ let output = Command::new(&solc)
+ .args([
+ "--combined-json",
+ "bin",
+ "--optimize",
+ "--optimize-runs",
+ "200",
+ &source,
+ ])
+ .output()
+ .unwrap_or_else(|e| panic!("failed to run solc at {solc}: {e}"));
+ assert!(
+ output.status.success(),
+ "solc failed for {source}:\n{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let compiled: Value = serde_json::from_slice(&output.stdout).unwrap();
+ let (_, artifact) = compiled["contracts"]
+ .as_object()
+ .unwrap()
+ .iter()
+ .find(|(key, _)| key.ends_with(&format!(":{contract}")))
+ .unwrap_or_else(|| panic!("solc did not produce contract {contract} from {source}"));
+ hex::decode(artifact["bin"].as_str().unwrap()).unwrap()
+}
+
+// ---------------------------------------------------------------------------
+// Small hex / receipt helpers.
+// ---------------------------------------------------------------------------
+
+fn status_ok(receipt: &Value) -> bool {
+ receipt["status"] == "0x1"
+}
+
+fn to_hex(bytes: &[u8]) -> String {
+ format!("0x{}", hex::encode(bytes))
+}
+
+fn from_hex(hex_str: &str) -> Vec {
+ hex::decode(hex_str.trim_start_matches("0x")).unwrap()
+}
+
+pub fn address_from_hex(hex_str: &str) -> Address {
+ Address::new(from_hex(hex_str).try_into().unwrap())
+}
diff --git a/rs/ethereum/cketh/test_utils/src/lib.rs b/rs/ethereum/cketh/test_utils/src/lib.rs
index 68016e42c5e2..2c7bb6023e33 100644
--- a/rs/ethereum/cketh/test_utils/src/lib.rs
+++ b/rs/ethereum/cketh/test_utils/src/lib.rs
@@ -37,10 +37,12 @@ use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
+pub mod anvil;
pub mod ckerc20;
pub mod events;
mod evm_rpc_provider;
pub mod flow;
+pub mod live_scan;
pub mod mock;
pub mod response;
diff --git a/rs/ethereum/cketh/test_utils/src/live_scan.rs b/rs/ethereum/cketh/test_utils/src/live_scan.rs
new file mode 100644
index 000000000000..4b8b240bbdea
--- /dev/null
+++ b/rs/ethereum/cketh/test_utils/src/live_scan.rs
@@ -0,0 +1,338 @@
+//! A live [`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.
+//!
+//! 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
+//! supported tokens can be registered directly via `add_ckerc20_token` without a real orchestrator
+//! or any spawned ledgers — the balance scan only needs the token contract addresses in the
+//! 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,
+};
+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 std::str::FromStr;
+use std::time::{Duration, Instant};
+
+use crate::anvil::{
+ Anvil, DEV_ACCOUNT, address_from_hex, deploy_mock_erc20, erc20_balance_slot, u256_be,
+};
+use crate::{
+ CKETH_MINIMUM_WITHDRAWAL_AMOUNT, ERC20_HELPER_CONTRACT_ADDRESS, ETH_HELPER_CONTRACT_ADDRESS,
+ USDC_ERC20_CONTRACT_ADDRESS, evm_rpc_wasm, minter_wasm,
+};
+
+/// USDT's mainnet address, the second token registered so the scan reads more than one token per
+/// address. Matches the ckUSDT contract the minter prices in `balance_scan::MIN_DEPOSITS`.
+pub const USDT_ERC20_CONTRACT_ADDRESS: &str = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
+
+/// A supported ckERC20 token the live scan reads, sitting at its real mainnet contract address.
+#[derive(Clone, Copy)]
+pub enum SupportedToken {
+ CkUsdc,
+ CkUsdt,
+}
+
+impl SupportedToken {
+ const ALL: [SupportedToken; 2] = [SupportedToken::CkUsdc, SupportedToken::CkUsdt];
+
+ fn contract(self) -> Address {
+ let address = match self {
+ SupportedToken::CkUsdc => USDC_ERC20_CONTRACT_ADDRESS,
+ SupportedToken::CkUsdt => USDT_ERC20_CONTRACT_ADDRESS,
+ };
+ Address::from_str(address).expect("BUG: hard-coded token address is invalid")
+ }
+}
+
+/// A balance to place on the owned anvil node: `amount` of `token` credited to the `deposit`
+/// address, so the scan reads a real balance for that (address, token) pair.
+pub struct Holding {
+ pub deposit: Address,
+ pub token: SupportedToken,
+ 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.
+fn controller() -> Principal {
+ Principal::from_slice(&[0x0a; 10])
+}
+
+pub struct CkErc20LiveScanSetup {
+ env: PocketIc,
+ anvil: Anvil,
+ minter_id: Principal,
+}
+
+impl CkErc20LiveScanSetup {
+ /// Starts a local anvil node, installs the minter and EVM RPC canister (the latter routed to
+ /// anvil via `overrideProvider`), registers ckUSDC/ckUSDT, and switches PocketIC to live mode so
+ /// 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();
+
+ let settings = CanisterSettings {
+ controllers: Some(vec![controller()]),
+ ..Default::default()
+ };
+
+ // A placeholder ckETH ledger: the minter stores its id at init but never calls it on the
+ // balance-scan path, so it is left uninstalled.
+ let ledger_id = env.create_canister();
+ 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 minter_id = env.create_canister_with_settings(Some(controller()), Some(settings));
+ env.add_cycles(minter_id, u128::from(u64::MAX));
+
+ // Go live *before* installing the minter: its install schedules immediate refresh and
+ // balance-scan timers that issue HTTPS outcalls, which would stall (holding the task guards)
+ // 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);
+ activate_ckerc20(&env, minter_id);
+ register_supported_tokens(&env, minter_id);
+
+ Self {
+ env,
+ anvil,
+ minter_id,
+ }
+ }
+
+ /// A distinct non-anonymous depositing principal for `seed`, so a test can register several
+ /// independent deposit addresses.
+ pub fn depositor(&self, seed: u64) -> Principal {
+ PrincipalId::new_user_test_id(seed).into()
+ }
+
+ /// Registers a deposit address for `caller`'s `subaccount` and returns the Ethereum address the
+ /// minter derived for it.
+ pub fn register_deposit_address(&self, caller: Principal, subaccount: [u8; 32]) -> Address {
+ Address::from_str(&self.deposit_erc20(caller, subaccount).address)
+ .expect("BUG: minter returned an invalid deposit address")
+ }
+
+ /// Calls `deposit_erc20` as `caller`, which registers (idempotently) that user's deposit
+ /// address for balance scanning and reports its scan progress.
+ pub fn deposit_erc20(&self, caller: Principal, subaccount: [u8; 32]) -> DepositErc20Response {
+ let arg = DepositErc20Arg {
+ mode: DepositMode::Unsponsored {
+ subaccount: Some(subaccount),
+ },
+ };
+ let reply = self
+ .env
+ .update_call(
+ self.minter_id,
+ caller,
+ "deposit_erc20",
+ Encode!(&arg).unwrap(),
+ )
+ .expect("BUG: deposit_erc20 was rejected");
+ Decode!(&reply, Result)
+ .unwrap()
+ .expect("BUG: deposit_erc20 returned an error")
+ }
+
+ /// Places both supported ERC-20s (ckUSDC, ckUSDT) at their real mainnet addresses on the owned
+ /// anvil node and credits each holding by writing its `balanceOf` mapping slot directly.
+ ///
+ /// Every balance is written *before* any token gets code. The fail-loud batcher only returns a
+ /// (scan-advancing) result once every token has code — by which point all balances are already
+ /// in place — so a concurrent scan can never observe a partially-credited state.
+ pub fn credit_deposits(&self, holdings: &[Holding]) {
+ let dev = address_from_hex(DEV_ACCOUNT);
+ // Reuse MockUSDT's deployed bytecode to give each token a working `balanceOf`.
+ let runtime = self.anvil.code(&deploy_mock_erc20(&self.anvil, &dev));
+
+ for holding in holdings {
+ self.anvil.set_storage_at(
+ &holding.token.contract(),
+ &erc20_balance_slot(&holding.deposit),
+ &u256_be(holding.amount),
+ );
+ }
+ // Every registered address is scanned against both tokens, so a token without code would
+ // revert the whole scan even for addresses that do not hold it.
+ for token in SupportedToken::ALL {
+ self.anvil.set_code(&token.contract(), &runtime);
+ }
+ for holding in holdings {
+ assert_eq!(
+ self.anvil
+ .erc20_balance(&holding.token.contract(), &holding.deposit),
+ Erc20Value::from(holding.amount),
+ "the deposit balance should be readable on anvil"
+ );
+ }
+ }
+
+ /// Waits until the minter's periodic balance scan has scanned `caller`'s deposit address —
+ /// observed through `deposit_erc20`'s own scan progress — and returns that progress. A failing
+ /// batch never advances an address, so `scan_count >= 1` already proves the `eth_call` against
+ /// anvil succeeded and decoded. Panics if no scan completes within `deadline`.
+ ///
+ /// Whether the address' balance made it a deposit *candidate* is not surfaced by
+ /// `deposit_erc20`; read that from [`Self::balance_scan_candidates`].
+ pub fn await_scan(
+ &self,
+ caller: Principal,
+ subaccount: [u8; 32],
+ deadline: Duration,
+ ) -> DepositErc20Response {
+ let start = Instant::now();
+ loop {
+ let progress = self.deposit_erc20(caller, subaccount);
+ if progress.scan_count >= 1 {
+ return progress;
+ }
+ assert!(
+ start.elapsed() <= deadline,
+ "the deposit address was not scanned within {deadline:?}"
+ );
+ std::thread::sleep(Duration::from_secs(2));
+ }
+ }
+
+ /// The greatest number of deposit candidates any balance scan reported, parsed from the
+ /// minter's `[balance_scan]` logs — the scan only logs the count, it is not otherwise exposed.
+ /// `deposit_erc20` reports that an address was scanned but not whether its balance cleared the
+ /// candidate threshold. `0` if no scan has logged a count yet.
+ pub fn balance_scan_candidates(&self) -> u64 {
+ // Canister logs are controller-only by default, so query them as the controller.
+ self.env
+ .fetch_canister_logs(self.minter_id, controller())
+ .expect("BUG: fetching the minter's canister logs failed")
+ .into_iter()
+ .filter_map(|record| candidates_in_log(&String::from_utf8_lossy(&record.content)))
+ .max()
+ .unwrap_or(0)
+ }
+}
+
+/// Extracts `N` from a `[balance_scan]: ... found N candidate(s) ...` log line, or `None` for any
+/// other line.
+fn candidates_in_log(line: &str) -> Option {
+ if !line.contains("[balance_scan]") {
+ return None;
+ }
+ line.split("found ")
+ .nth(1)?
+ .split_whitespace()
+ .next()?
+ .parse()
+ .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()
+ };
+ env.install_canister(
+ evm_rpc_id,
+ evm_rpc_wasm(),
+ Encode!(&args).unwrap(),
+ Some(controller()),
+ );
+}
+
+fn install_minter(
+ env: &PocketIc,
+ minter_id: Principal,
+ ledger_id: Principal,
+ evm_rpc_id: Principal,
+) {
+ let args = MinterInitArgs {
+ ethereum_network: EthereumNetwork::Mainnet,
+ ecdsa_key_name: ECDSA_KEY_NAME.to_string(),
+ 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,
+ minimum_withdrawal_amount: Nat::from(CKETH_MINIMUM_WITHDRAWAL_AMOUNT),
+ next_transaction_nonce: Nat::from(0_u8),
+ last_scraped_block_number: Nat::from(0_u8),
+ evm_rpc_id: Some(evm_rpc_id),
+ };
+ env.install_canister(
+ minter_id,
+ minter_wasm(),
+ Encode!(&MinterArg::InitArg(args)).unwrap(),
+ Some(controller()),
+ );
+}
+
+/// Activates the ckERC20 feature by pointing the minter's orchestrator id at [`controller`] (so
+/// this harness can register tokens) and setting the ERC-20 deposit helper contract.
+fn activate_ckerc20(env: &PocketIc, minter_id: Principal) {
+ let upgrade = UpgradeArg {
+ ledger_suite_orchestrator_id: Some(controller()),
+ erc20_helper_contract_address: Some(ERC20_HELPER_CONTRACT_ADDRESS.to_string()),
+ ..Default::default()
+ };
+ env.upgrade_canister(
+ minter_id,
+ minter_wasm(),
+ Encode!(&MinterArg::UpgradeArg(upgrade)).unwrap(),
+ Some(controller()),
+ )
+ .expect("BUG: failed to activate the ckERC20 feature");
+}
+
+fn register_supported_tokens(env: &PocketIc, minter_id: Principal) {
+ for (address, symbol) in [
+ (USDC_ERC20_CONTRACT_ADDRESS, "ckUSDC"),
+ (USDT_ERC20_CONTRACT_ADDRESS, "ckUSDT"),
+ ] {
+ let arg = AddCkErc20Token {
+ chain_id: Nat::from(1_u8),
+ address: address.to_string(),
+ ckerc20_token_symbol: symbol.to_string(),
+ // A distinct placeholder ledger per token: the minter rejects duplicate ledger ids and
+ // never calls these on the balance-scan path.
+ ckerc20_ledger_id: env.create_canister(),
+ };
+ env.update_call(
+ minter_id,
+ controller(),
+ "add_ckerc20_token",
+ Encode!(&arg).unwrap(),
+ )
+ .expect("BUG: add_ckerc20_token was rejected");
+ }
+}