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"); + } +}