From dd0ddd3df3b0ef82403dbf3b5695c4251fdbc4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 11:36:43 +0200 Subject: [PATCH 01/25] Add migrated scenario-1-pocketic as a separate crate alongside the existing dfx test --- Cargo.lock | 9 + Cargo.toml | 1 + e2e-tests/scenario-1-pocketic/Cargo.toml | 10 + e2e-tests/scenario-1-pocketic/src/lib.rs | 190 ++++++++++++++++++ .../scenario-1-pocketic/tests/integration.rs | 142 +++++++++++++ 5 files changed, 352 insertions(+) create mode 100644 e2e-tests/scenario-1-pocketic/Cargo.toml create mode 100644 e2e-tests/scenario-1-pocketic/src/lib.rs create mode 100644 e2e-tests/scenario-1-pocketic/tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock index 0b4824f3..f900ba87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1221,6 +1221,15 @@ dependencies = [ "pocket-ic", ] +[[package]] +name = "e2e-scenario-1-pocketic" +version = "0.1.0" +dependencies = [ + "candid", + "ic-btc-interface", + "pocket-ic", +] + [[package]] name = "ecdsa" version = "0.16.9" diff --git a/Cargo.toml b/Cargo.toml index 6a554394..fac19abc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "e2e-tests/scenario-3", "e2e-tests/cdk-bitcoin-canister", "e2e-tests/disable-api-if-not-fully-synced-flag", + "e2e-tests/scenario-1-pocketic", "test-utils", "ic-http", "ic-http/example_canister/src/canister_backend", diff --git a/e2e-tests/scenario-1-pocketic/Cargo.toml b/e2e-tests/scenario-1-pocketic/Cargo.toml new file mode 100644 index 00000000..f50be9ba --- /dev/null +++ b/e2e-tests/scenario-1-pocketic/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "e2e-scenario-1-pocketic" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +candid = { workspace = true } +ic-btc-interface = { workspace = true } +pocket-ic = { workspace = true } diff --git a/e2e-tests/scenario-1-pocketic/src/lib.rs b/e2e-tests/scenario-1-pocketic/src/lib.rs new file mode 100644 index 00000000..79f9e0eb --- /dev/null +++ b/e2e-tests/scenario-1-pocketic/src/lib.rs @@ -0,0 +1,190 @@ +use candid::{CandidType, Deserialize, Principal}; +use ic_btc_interface::{ + BlockchainInfo, CanisterArg, GetBalanceRequest, GetBlockHeadersRequest, + GetBlockHeadersResponse, GetCurrentFeePercentilesRequest, GetUtxosRequest, GetUtxosResponse, + InitConfig, Network, +}; +use pocket_ic::{PocketIc, PocketIcBuilder, RejectResponse}; +use std::{path::PathBuf, process::Command}; + +pub struct Setup { + pub pic: PocketIc, + pub source_id: Principal, + pub btc_id: Principal, +} + +impl Setup { + pub fn new() -> Self { + let source_wasm = load_wasm("E2E_SCENARIO_1_WASM_PATH", "scenario-1"); + let btc_wasm = load_wasm("IC_BTC_CANISTER_WASM_PATH", "ic-btc-canister"); + + let pic = PocketIcBuilder::new().with_bitcoin_subnet().build(); + + let source_id = pic.create_canister(); + pic.add_cycles(source_id, 10_000_000_000_000); + pic.install_canister(source_id, source_wasm, vec![], None); + + let btc_id = pic.create_canister(); + pic.add_cycles(btc_id, 10_000_000_000_000); + pic.install_canister( + btc_id, + btc_wasm, + candid::encode_one(CanisterArg::Init(InitConfig { + stability_threshold: Some(2), + network: Some(Network::Regtest), + blocks_source: Some(source_id), + ..Default::default() + })) + .unwrap(), + None, + ); + + Self { + pic, + source_id, + btc_id, + } + } + + pub fn tick(&self) { + self.pic.tick(); + } + + pub fn tick_until_main_chain_height(&self, target: u32, max_ticks: u32) { + for _ in 0..max_ticks { + self.pic.tick(); + let reached = self + .pic + .query_call( + self.btc_id, + Principal::anonymous(), + "get_blockchain_info", + candid::encode_args(()).unwrap(), + ) + .ok() + .and_then(|b| candid::decode_one::(&b).ok()) + .map(|info| info.height >= target) + .unwrap_or(false); + if reached { + return; + } + } + panic!("timed out after {max_ticks} ticks waiting for main chain height {target}"); + } + + pub fn get_blockchain_info(&self) -> BlockchainInfo { + let bytes = self + .pic + .query_call( + self.btc_id, + Principal::anonymous(), + "get_blockchain_info", + candid::encode_args(()).unwrap(), + ) + .expect("get_blockchain_info query failed"); + candid::decode_one(&bytes).expect("failed to decode BlockchainInfo") + } + + pub fn bitcoin_get_balance(&self, req: GetBalanceRequest) -> u64 { + self.update("bitcoin_get_balance", req) + } + + pub fn bitcoin_get_balance_query(&self, req: GetBalanceRequest) -> u64 { + self.query("bitcoin_get_balance_query", req) + } + + pub fn bitcoin_get_utxos(&self, req: GetUtxosRequest) -> GetUtxosResponse { + self.update("bitcoin_get_utxos", req) + } + + pub fn bitcoin_get_utxos_query(&self, req: GetUtxosRequest) -> GetUtxosResponse { + self.query("bitcoin_get_utxos_query", req) + } + + pub fn bitcoin_get_block_headers( + &self, + req: GetBlockHeadersRequest, + ) -> GetBlockHeadersResponse { + self.update("bitcoin_get_block_headers", req) + } + + pub fn bitcoin_get_current_fee_percentiles( + &self, + req: GetCurrentFeePercentilesRequest, + ) -> Vec { + self.update("bitcoin_get_current_fee_percentiles", req) + } + + /// Makes an update call and returns the raw result, including any rejection. + /// Use this to test that a method rejects when called in replicated mode. + pub fn update_call_raw( + &self, + method: &str, + arg: impl CandidType, + ) -> Result, RejectResponse> { + self.pic.update_call( + self.btc_id, + Principal::anonymous(), + method, + candid::encode_one(arg).unwrap(), + ) + } + + fn query Deserialize<'de>>( + &self, + method: &str, + arg: impl CandidType, + ) -> T { + let bytes = self + .pic + .query_call( + self.btc_id, + Principal::anonymous(), + method, + candid::encode_one(arg).unwrap(), + ) + .unwrap_or_else(|e| panic!("{method} query failed: {e:?}")); + candid::decode_one(&bytes).unwrap_or_else(|e| panic!("decode {method} response: {e}")) + } + + fn update Deserialize<'de>>( + &self, + method: &str, + arg: impl CandidType, + ) -> T { + let bytes = self + .pic + .update_call( + self.btc_id, + Principal::anonymous(), + method, + candid::encode_one(arg).unwrap(), + ) + .unwrap_or_else(|e| panic!("{method} update call failed: {e:?}")); + candid::decode_one(&bytes).unwrap_or_else(|e| panic!("decode {method} response: {e}")) + } +} + +fn load_wasm(env_var: &str, canister_name: &str) -> Vec { + if let Ok(path) = std::env::var(env_var) { + return std::fs::read(&path) + .unwrap_or_else(|e| panic!("failed to read WASM from {path}: {e}")); + } + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("bash") + .arg(repo_root.join("scripts/build-canister.sh")) + .arg(canister_name) + .current_dir(&repo_root) + .output() + .unwrap_or_else(|e| panic!("failed to spawn build-canister.sh for {canister_name}: {e}")); + assert!( + output.status.success(), + "build-canister.sh {canister_name} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let wasm_path = repo_root.join(format!( + "target/wasm32-unknown-unknown/release/{canister_name}.wasm.gz" + )); + std::fs::read(&wasm_path) + .unwrap_or_else(|e| panic!("failed to read WASM from {wasm_path:?}: {e}")) +} diff --git a/e2e-tests/scenario-1-pocketic/tests/integration.rs b/e2e-tests/scenario-1-pocketic/tests/integration.rs new file mode 100644 index 00000000..e1297fa2 --- /dev/null +++ b/e2e-tests/scenario-1-pocketic/tests/integration.rs @@ -0,0 +1,142 @@ +use e2e_scenario_1_pocketic::Setup; +use ic_btc_interface::{ + GetBalanceRequest, GetBlockHeadersRequest, GetCurrentFeePercentilesRequest, GetUtxosRequest, + NetworkInRequest, +}; +use pocket_ic::RejectCode; + +// Addresses defined in e2e-tests/scenario-1/src/main.rs +const ADDRESS_1: &str = "bcrt1qg4cvn305es3k8j69x06t9hf4v5yx4mxdaeazl8"; +const ADDRESS_2: &str = "bcrt1qxp8ercrmfxlu0s543najcj6fe6267j97tv7rgf"; +const ADDRESS_5: &str = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; + +fn balance_req(address: &str, min_confirmations: Option) -> GetBalanceRequest { + GetBalanceRequest { + address: address.to_string(), + network: NetworkInRequest::Regtest, + min_confirmations, + } +} + +fn utxos_req(address: &str) -> GetUtxosRequest { + GetUtxosRequest { + address: address.to_string(), + network: NetworkInRequest::Regtest, + filter: None, + } +} + +#[test] +fn scenario_1() { + let setup = Setup::new(); + + // Wait until all 5 blocks have been ingested. + setup.tick_until_main_chain_height(5, 500); + + let info = setup.get_blockchain_info(); + assert_eq!(info.height, 5, "expected blockchain height 5, got {}", info.height); + + // Tick more to let stable-block processing complete (blocks 1–3 are stable with + // stability_threshold=2 once main_chain_height=5). + for _ in 0..100 { + setup.tick(); + } + + // ADDRESS_1 has no balance: it transferred everything to ADDRESS_2 in block 2. + assert_eq!(setup.bitcoin_get_balance(balance_req(ADDRESS_1, None)), 0); + assert_eq!(setup.bitcoin_get_balance_query(balance_req(ADDRESS_1, None)), 0); + + // ADDRESS_2 with min_confirmations=2: block 5's spend is excluded (only 1 confirmation at + // tip), so it still shows the 50 BTC received in block 2. + assert_eq!( + setup.bitcoin_get_balance(balance_req(ADDRESS_2, Some(2))), + 5_000_000_000 + ); + + // ADDRESS_2 UTXOs without filter: block 5 is included so all are spent. + assert_eq!(setup.bitcoin_get_utxos(utxos_req(ADDRESS_2)).utxos.len(), 0); + assert_eq!(setup.bitcoin_get_utxos_query(utxos_req(ADDRESS_2)).utxos.len(), 0); + + // ADDRESS_5 has 10k UTXOs (received in block 5), but responses are capped at 1000. + assert_eq!(setup.bitcoin_get_utxos(utxos_req(ADDRESS_5)).utxos.len(), 1000); + assert_eq!(setup.bitcoin_get_utxos_query(utxos_req(ADDRESS_5)).utxos.len(), 1000); + + // Calling query-only methods as replicated (update) calls must be rejected. + let err = setup + .update_call_raw("bitcoin_get_utxos_query", utxos_req(ADDRESS_5)) + .expect_err("expected replicated bitcoin_get_utxos_query to be rejected"); + assert_eq!(err.reject_code, RejectCode::CanisterReject); + + let err = setup + .update_call_raw("bitcoin_get_balance_query", balance_req(ADDRESS_5, None)) + .expect_err("expected replicated bitcoin_get_balance_query to be rejected"); + assert_eq!(err.reject_code, RejectCode::CanisterReject); + + // ADDRESS_5 balance. + assert_eq!(setup.bitcoin_get_balance(balance_req(ADDRESS_5, None)), 5_000_000_000); + assert_eq!(setup.bitcoin_get_balance_query(balance_req(ADDRESS_5, None)), 5_000_000_000); + + // Fee percentiles smoke test. + let fee_req = || GetCurrentFeePercentilesRequest { + network: NetworkInRequest::Regtest, + }; + setup.bitcoin_get_current_fee_percentiles(fee_req()); + setup.bitcoin_get_current_fee_percentiles(fee_req()); + + // Verify block headers. The scenario-1 canister chains 5 blocks onto the genesis block, + // so get_block_headers returns 6 headers (genesis + blocks 1–5). + let headers_resp = setup.bitcoin_get_block_headers(GetBlockHeadersRequest { + start_height: 0, + end_height: None, + network: NetworkInRequest::Regtest, + }); + assert_eq!(headers_resp.tip_height, 5); + + // Expected headers are the raw 80-byte Bitcoin block headers, matching the blob literals + // in scenario-1.sh. Each \xNN byte corresponds to the \NN hex escape in the Candid blobs. + let expected_headers: Vec> = vec![ + // Genesis block header + b"\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ + \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ + \x00\x00\x00\x00\x3b\xa3\xed\xfd\x7a\x7b\x12\xb2\x7a\xc7\x2c\x3e\ + \x67\x76\x8f\x61\x7f\xc8\x1b\xc3\x88\x8a\x51\x32\x3a\x9f\xb8\xaa\ + \x4b\x1e\x5e\x4a\xda\xe5\x49\x4d\xff\xff\x7f\x20\x02\x00\x00\x00" + .to_vec(), + // Block 1 + b"\x01\x00\x00\x00\x06\x22\x6e\x46\x11\x1a\x0b\x59\xca\xaf\x12\x60\ + \x43\xeb\x5b\xbf\x28\xc3\x4f\x3a\x5e\x33\x2a\x1f\xc7\xb2\xb7\x3c\ + \xf1\x88\x91\x0f\xf0\xbd\x3e\x7d\xa3\xbc\x8d\xc6\x62\x68\x28\xb3\ + \x66\x7a\x16\xba\x4e\xef\x63\x96\x6a\x68\xeb\x4d\xfd\xae\xd7\xf1\ + \x6f\x41\x97\xc8\x32\xe8\x49\x4d\xff\xff\x7f\x20\x00\x00\x00\x00" + .to_vec(), + // Block 2 + b"\x01\x00\x00\x00\xb5\x2a\x48\x82\x73\x2c\x0c\xe4\x6f\x9c\x91\xa3\ + \x71\xe3\xee\x7f\x33\x02\x9b\x09\x50\x2d\xaf\x59\x8e\x5e\x2d\x4e\ + \xc2\x00\x89\x56\xf2\x83\x4a\xe9\xa7\x78\xd3\x58\x67\x63\x7e\x17\ + \xb9\xf6\x75\x5e\x03\xdd\xbb\x8c\x52\x1b\x9a\xd6\x07\xb5\xbb\xab\ + \xee\xa1\x15\x33\x8a\xea\x49\x4d\xff\xff\x7f\x20\x00\x00\x00\x00" + .to_vec(), + // Block 3 + b"\x01\x00\x00\x00\x9d\x9d\x5d\xb6\x5e\x61\x2a\xf4\xef\x18\xe2\x50\ + \xa8\x2a\x30\x8e\xa1\xd3\x49\xeb\x96\x88\x3b\x12\x1c\x90\x52\x35\ + \x6d\x83\x10\x69\x7e\xde\xe2\x2e\x85\x73\x88\x87\xce\x80\x9e\xc6\ + \xcf\xdf\x6c\xba\x43\xcc\xee\x51\xa9\x6e\x9a\xe6\xba\xe9\x22\x71\ + \x39\xc5\xe2\x07\xe2\xec\x49\x4d\xff\xff\x7f\x20\x01\x00\x00\x00" + .to_vec(), + // Block 4 + b"\x01\x00\x00\x00\xc2\x34\xc0\xc4\x59\x61\x6d\x2c\x1f\xb0\xab\xa3\ + \x92\xf5\xe7\xc2\x5d\xe3\x83\x3b\x9b\x35\xa7\x41\x1c\x4e\x9d\x08\ + \x15\x27\xfd\x55\x47\xe2\xc5\x8e\x39\x9b\x85\xd6\xfc\xe6\xbc\x46\ + \x7d\x52\x1a\x5a\x6f\x54\x1f\x02\x4c\xe2\x8e\x88\x27\xcd\xe1\xe4\ + \x23\xb2\x13\x3a\x3a\xef\x49\x4d\xff\xff\x7f\x20\x02\x00\x00\x00" + .to_vec(), + // Block 5 + b"\x01\x00\x00\x00\x09\xca\xab\xac\x0a\xf4\x33\x86\x14\x54\x63\x62\ + \x3f\xe9\x15\x03\x2e\xec\xa0\xda\x02\x1b\x03\xa0\x48\xbe\x22\x21\ + \xfc\xd7\x49\x54\x00\x51\x6d\x88\xc9\x36\x80\x03\xbe\x61\x36\xce\ + \x35\x41\x8b\xd3\xac\x40\x9f\x1c\xab\x5c\xed\xac\x4e\xbb\x56\x33\ + \x34\x9b\xfa\xe5\x92\xf1\x49\x4d\xff\xff\x7f\x20\x01\x00\x00\x00" + .to_vec(), + ]; + assert_eq!(headers_resp.block_headers, expected_headers); +} From 4790193792284b6255502092da8c8f9c1f423778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 11:37:01 +0200 Subject: [PATCH 02/25] Replace the old dfx test with the new pocketic test for scenario-1 --- Cargo.lock | 11 +- Cargo.toml | 1 - e2e-tests/scenario-1-pocketic/Cargo.toml | 10 - e2e-tests/scenario-1-pocketic/src/lib.rs | 190 ---------------- e2e-tests/scenario-1.sh | 214 ------------------ e2e-tests/scenario-1/Cargo.toml | 5 + .../tests/integration.rs | 184 ++++++++++++++- 7 files changed, 186 insertions(+), 429 deletions(-) delete mode 100644 e2e-tests/scenario-1-pocketic/Cargo.toml delete mode 100644 e2e-tests/scenario-1-pocketic/src/lib.rs delete mode 100755 e2e-tests/scenario-1.sh rename e2e-tests/{scenario-1-pocketic => scenario-1}/tests/integration.rs (52%) diff --git a/Cargo.lock b/Cargo.lock index f900ba87..b36ca5d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1221,15 +1221,6 @@ dependencies = [ "pocket-ic", ] -[[package]] -name = "e2e-scenario-1-pocketic" -version = "0.1.0" -dependencies = [ - "candid", - "ic-btc-interface", - "pocket-ic", -] - [[package]] name = "ecdsa" version = "0.16.9" @@ -3762,8 +3753,10 @@ version = "0.1.0" dependencies = [ "bitcoin-dogecoin", "candid", + "ic-btc-interface", "ic-btc-test-utils", "ic-cdk 0.20.0", + "pocket-ic", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index fac19abc..6a554394 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ members = [ "e2e-tests/scenario-3", "e2e-tests/cdk-bitcoin-canister", "e2e-tests/disable-api-if-not-fully-synced-flag", - "e2e-tests/scenario-1-pocketic", "test-utils", "ic-http", "ic-http/example_canister/src/canister_backend", diff --git a/e2e-tests/scenario-1-pocketic/Cargo.toml b/e2e-tests/scenario-1-pocketic/Cargo.toml deleted file mode 100644 index f50be9ba..00000000 --- a/e2e-tests/scenario-1-pocketic/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "e2e-scenario-1-pocketic" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -candid = { workspace = true } -ic-btc-interface = { workspace = true } -pocket-ic = { workspace = true } diff --git a/e2e-tests/scenario-1-pocketic/src/lib.rs b/e2e-tests/scenario-1-pocketic/src/lib.rs deleted file mode 100644 index 79f9e0eb..00000000 --- a/e2e-tests/scenario-1-pocketic/src/lib.rs +++ /dev/null @@ -1,190 +0,0 @@ -use candid::{CandidType, Deserialize, Principal}; -use ic_btc_interface::{ - BlockchainInfo, CanisterArg, GetBalanceRequest, GetBlockHeadersRequest, - GetBlockHeadersResponse, GetCurrentFeePercentilesRequest, GetUtxosRequest, GetUtxosResponse, - InitConfig, Network, -}; -use pocket_ic::{PocketIc, PocketIcBuilder, RejectResponse}; -use std::{path::PathBuf, process::Command}; - -pub struct Setup { - pub pic: PocketIc, - pub source_id: Principal, - pub btc_id: Principal, -} - -impl Setup { - pub fn new() -> Self { - let source_wasm = load_wasm("E2E_SCENARIO_1_WASM_PATH", "scenario-1"); - let btc_wasm = load_wasm("IC_BTC_CANISTER_WASM_PATH", "ic-btc-canister"); - - let pic = PocketIcBuilder::new().with_bitcoin_subnet().build(); - - let source_id = pic.create_canister(); - pic.add_cycles(source_id, 10_000_000_000_000); - pic.install_canister(source_id, source_wasm, vec![], None); - - let btc_id = pic.create_canister(); - pic.add_cycles(btc_id, 10_000_000_000_000); - pic.install_canister( - btc_id, - btc_wasm, - candid::encode_one(CanisterArg::Init(InitConfig { - stability_threshold: Some(2), - network: Some(Network::Regtest), - blocks_source: Some(source_id), - ..Default::default() - })) - .unwrap(), - None, - ); - - Self { - pic, - source_id, - btc_id, - } - } - - pub fn tick(&self) { - self.pic.tick(); - } - - pub fn tick_until_main_chain_height(&self, target: u32, max_ticks: u32) { - for _ in 0..max_ticks { - self.pic.tick(); - let reached = self - .pic - .query_call( - self.btc_id, - Principal::anonymous(), - "get_blockchain_info", - candid::encode_args(()).unwrap(), - ) - .ok() - .and_then(|b| candid::decode_one::(&b).ok()) - .map(|info| info.height >= target) - .unwrap_or(false); - if reached { - return; - } - } - panic!("timed out after {max_ticks} ticks waiting for main chain height {target}"); - } - - pub fn get_blockchain_info(&self) -> BlockchainInfo { - let bytes = self - .pic - .query_call( - self.btc_id, - Principal::anonymous(), - "get_blockchain_info", - candid::encode_args(()).unwrap(), - ) - .expect("get_blockchain_info query failed"); - candid::decode_one(&bytes).expect("failed to decode BlockchainInfo") - } - - pub fn bitcoin_get_balance(&self, req: GetBalanceRequest) -> u64 { - self.update("bitcoin_get_balance", req) - } - - pub fn bitcoin_get_balance_query(&self, req: GetBalanceRequest) -> u64 { - self.query("bitcoin_get_balance_query", req) - } - - pub fn bitcoin_get_utxos(&self, req: GetUtxosRequest) -> GetUtxosResponse { - self.update("bitcoin_get_utxos", req) - } - - pub fn bitcoin_get_utxos_query(&self, req: GetUtxosRequest) -> GetUtxosResponse { - self.query("bitcoin_get_utxos_query", req) - } - - pub fn bitcoin_get_block_headers( - &self, - req: GetBlockHeadersRequest, - ) -> GetBlockHeadersResponse { - self.update("bitcoin_get_block_headers", req) - } - - pub fn bitcoin_get_current_fee_percentiles( - &self, - req: GetCurrentFeePercentilesRequest, - ) -> Vec { - self.update("bitcoin_get_current_fee_percentiles", req) - } - - /// Makes an update call and returns the raw result, including any rejection. - /// Use this to test that a method rejects when called in replicated mode. - pub fn update_call_raw( - &self, - method: &str, - arg: impl CandidType, - ) -> Result, RejectResponse> { - self.pic.update_call( - self.btc_id, - Principal::anonymous(), - method, - candid::encode_one(arg).unwrap(), - ) - } - - fn query Deserialize<'de>>( - &self, - method: &str, - arg: impl CandidType, - ) -> T { - let bytes = self - .pic - .query_call( - self.btc_id, - Principal::anonymous(), - method, - candid::encode_one(arg).unwrap(), - ) - .unwrap_or_else(|e| panic!("{method} query failed: {e:?}")); - candid::decode_one(&bytes).unwrap_or_else(|e| panic!("decode {method} response: {e}")) - } - - fn update Deserialize<'de>>( - &self, - method: &str, - arg: impl CandidType, - ) -> T { - let bytes = self - .pic - .update_call( - self.btc_id, - Principal::anonymous(), - method, - candid::encode_one(arg).unwrap(), - ) - .unwrap_or_else(|e| panic!("{method} update call failed: {e:?}")); - candid::decode_one(&bytes).unwrap_or_else(|e| panic!("decode {method} response: {e}")) - } -} - -fn load_wasm(env_var: &str, canister_name: &str) -> Vec { - if let Ok(path) = std::env::var(env_var) { - return std::fs::read(&path) - .unwrap_or_else(|e| panic!("failed to read WASM from {path}: {e}")); - } - let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - let output = Command::new("bash") - .arg(repo_root.join("scripts/build-canister.sh")) - .arg(canister_name) - .current_dir(&repo_root) - .output() - .unwrap_or_else(|e| panic!("failed to spawn build-canister.sh for {canister_name}: {e}")); - assert!( - output.status.success(), - "build-canister.sh {canister_name} failed:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - let wasm_path = repo_root.join(format!( - "target/wasm32-unknown-unknown/release/{canister_name}.wasm.gz" - )); - std::fs::read(&wasm_path) - .unwrap_or_else(|e| panic!("failed to read WASM from {wasm_path:?}: {e}")) -} diff --git a/e2e-tests/scenario-1.sh b/e2e-tests/scenario-1.sh deleted file mode 100755 index 80a573bd..00000000 --- a/e2e-tests/scenario-1.sh +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env bash -set -Eexuo pipefail - -SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -source "${SCRIPT_DIR}/utils.sh" -pushd "$SCRIPT_DIR" - -# Run dfx stop if we run into errors. -trap "dfx stop" EXIT SIGINT - -dfx start --background --clean - -# Deploy the canister that returns the blocks for scenario 1. -dfx deploy --no-wallet e2e-scenario-1 - -# Configure dfx.json to use pre-built WASM -use_prebuilt_bitcoin_wasm - -# Deploy the bitcoin canister, setting the blocks_source to be the source above. -dfx deploy --no-wallet bitcoin --argument "(variant {init = record { - stability_threshold = opt 2; - network = opt variant { regtest }; - blocks_source = opt principal \"$(dfx canister id e2e-scenario-1)\"; -}})" - -# Wait until all blocks have been received. -wait_until_main_chain_height 5 60 - -# Verify the blockchain info using the query endpoint. -BLOCKCHAIN_INFO=$(dfx canister call bitcoin get_blockchain_info --query) -if ! [[ $BLOCKCHAIN_INFO == *"height = 5"* ]]; then - echo "FAIL: Expected height 5 in blockchain info, got $BLOCKCHAIN_INFO" - exit 1 -fi - -# Wait until the ingestion of stable blocks is complete. -wait_until_stable_height 3 60 - -# Fetch the balance of an address we do not expect to have funds. -BALANCE=$(dfx canister call bitcoin bitcoin_get_balance '(record { - network = variant { regtest }; - address = "bcrt1qg4cvn305es3k8j69x06t9hf4v5yx4mxdaeazl8" -})') - -if ! [[ $BALANCE = "(0 : nat64)" ]]; then - echo "FAIL" - exit 1 -fi - -BALANCE=$(dfx canister call --query bitcoin bitcoin_get_balance_query '(record { - network = variant { regtest }; - address = "bcrt1qg4cvn305es3k8j69x06t9hf4v5yx4mxdaeazl8" -})') - -if ! [[ $BALANCE = "(0 : nat64)" ]]; then - echo "FAIL" - exit 1 -fi - -# Fetch the balance of an address we expect to have funds. -BALANCE=$(dfx canister call bitcoin bitcoin_get_balance '(record { - network = variant { regtest }; - address = "bcrt1qxp8ercrmfxlu0s543najcj6fe6267j97tv7rgf"; - min_confirmations = opt 2; -})') - -# Verify that the balance is 50 BTC. -if ! [[ $BALANCE = "(5_000_000_000 : nat64)" ]]; then - echo "FAIL" - exit 1 -fi - -UTXOS=$(dfx canister call bitcoin bitcoin_get_utxos '(record { - network = variant { regtest }; - address = "bcrt1qxp8ercrmfxlu0s543najcj6fe6267j97tv7rgf"; -})') - -# The address has no UTXOs. -if ! [[ $(num_utxos "$UTXOS") = 0 ]]; then - echo "FAIL" - exit 1 -fi - -UTXOS=$(dfx canister call --query bitcoin bitcoin_get_utxos_query '(record { - network = variant { regtest }; - address = "bcrt1qxp8ercrmfxlu0s543najcj6fe6267j97tv7rgf"; -})') - -# The address has no UTXOs. -if ! [[ $(num_utxos "$UTXOS") = 0 ]]; then - echo "FAIL" - exit 1 -fi - -# Verify that we are able to fetch the UTXOs of one address. -# We temporarily pause outputting the commands to the terminal as -# this command would print thousands of UTXOs. -set +x -UTXOS=$(dfx canister call --query bitcoin bitcoin_get_utxos_query '(record { - network = variant { regtest }; - address = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh" -})') - -# The address has 10000 UTXOs, but the response is capped to 1000 UTXOs. -if ! [[ $(num_utxos "$UTXOS") = 1000 ]]; then - echo "FAIL" - exit 1 -fi -set -x - -set +x -UTXOS=$(dfx canister call bitcoin bitcoin_get_utxos_query '(record { - network = variant { regtest }; - address = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh" -})') - -# The address has 10000 UTXOs, but the response is capped to 1000 UTXOs. -if ! [[ $(num_utxos "$UTXOS") = 1000 ]]; then - echo "FAIL" - exit 1 -fi -set -x - -# Check that 'bitcoin_get_utxos_query' cannot be called in replicated mode. -set +e -GET_UTXOS_QUERY_REPLICATED_CALL=$(dfx canister call --update bitcoin bitcoin_get_utxos_query '(record { - network = variant { regtest }; - address = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; -})' 2>&1) -set -e - -if [[ $GET_UTXOS_QUERY_REPLICATED_CALL != *"CanisterReject"* ]]; then - echo "FAIL" - exit 1 -fi - -BALANCE=$(dfx canister call --query bitcoin bitcoin_get_balance_query '(record { - network = variant { regtest }; - address = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; -})') - -if ! [[ $BALANCE = "(5_000_000_000 : nat64)" ]]; then - echo "FAIL" - exit 1 -fi - -# Check that 'bitcoin_get_balance_query' cannot be called in replicated mode. -set +e -GET_BALANCE_QUERY_REPLICATED_CALL=$(dfx canister call --update bitcoin bitcoin_get_balance_query '(record { - network = variant { regtest }; - address = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; -})' 2>&1) -set -e - -if [[ $GET_BALANCE_QUERY_REPLICATED_CALL != *"CanisterReject"* ]]; then - echo "FAIL" - exit 1 -fi - -BALANCE=$(dfx canister call bitcoin bitcoin_get_balance '(record { - network = variant { regtest }; - address = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; -})') - -if ! [[ $BALANCE = "(5_000_000_000 : nat64)" ]]; then - echo "FAIL" - exit 1 -fi - -BALANCE=$(dfx canister call --query bitcoin bitcoin_get_balance_query '(record { - network = variant { regtest }; - address = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; -})') - -if ! [[ $BALANCE = "(5_000_000_000 : nat64)" ]]; then - echo "FAIL" - exit 1 -fi - -# Request the current fee percentiles. This is only for profiling purposes. -dfx canister call bitcoin bitcoin_get_current_fee_percentiles '(record { - network = variant { regtest }; -})' -dfx canister call bitcoin bitcoin_get_current_fee_percentiles '(record { - network = variant { regtest }; -})' - -# Verify that we can fetch the block headers. -ACTUAL_HEADERS=$(dfx canister call bitcoin bitcoin_get_block_headers '(record { - start_height = 0; - network = variant { regtest }; -})'); - -# The e2e-scenario-1 canister chains 5 blocks onto the genesis block. -EXPECTED_HEADERS='( - record { - tip_height = 5 : nat32; - block_headers = vec { - blob "\01\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\3b\a3\ed\fd\7a\7b\12\b2\7a\c7\2c\3e\67\76\8f\61\7f\c8\1b\c3\88\8a\51\32\3a\9f\b8\aa\4b\1e\5e\4a\da\e5\49\4d\ff\ff\7f\20\02\00\00\00"; - blob "\01\00\00\00\06\22\6e\46\11\1a\0b\59\ca\af\12\60\43\eb\5b\bf\28\c3\4f\3a\5e\33\2a\1f\c7\b2\b7\3c\f1\88\91\0f\f0\bd\3e\7d\a3\bc\8d\c6\62\68\28\b3\66\7a\16\ba\4e\ef\63\96\6a\68\eb\4d\fd\ae\d7\f1\6f\41\97\c8\32\e8\49\4d\ff\ff\7f\20\00\00\00\00"; - blob "\01\00\00\00\b5\2a\48\82\73\2c\0c\e4\6f\9c\91\a3\71\e3\ee\7f\33\02\9b\09\50\2d\af\59\8e\5e\2d\4e\c2\00\89\56\f2\83\4a\e9\a7\78\d3\58\67\63\7e\17\b9\f6\75\5e\03\dd\bb\8c\52\1b\9a\d6\07\b5\bb\ab\ee\a1\15\33\8a\ea\49\4d\ff\ff\7f\20\00\00\00\00"; - blob "\01\00\00\00\9d\9d\5d\b6\5e\61\2a\f4\ef\18\e2\50\a8\2a\30\8e\a1\d3\49\eb\96\88\3b\12\1c\90\52\35\6d\83\10\69\7e\de\e2\2e\85\73\88\87\ce\80\9e\c6\cf\df\6c\ba\43\cc\ee\51\a9\6e\9a\e6\ba\e9\22\71\39\c5\e2\07\e2\ec\49\4d\ff\ff\7f\20\01\00\00\00"; - blob "\01\00\00\00\c2\34\c0\c4\59\61\6d\2c\1f\b0\ab\a3\92\f5\e7\c2\5d\e3\83\3b\9b\35\a7\41\1c\4e\9d\08\15\27\fd\55\47\e2\c5\8e\39\9b\85\d6\fc\e6\bc\46\7d\52\1a\5a\6f\54\1f\02\4c\e2\8e\88\27\cd\e1\e4\23\b2\13\3a\3a\ef\49\4d\ff\ff\7f\20\02\00\00\00"; - blob "\01\00\00\00\09\ca\ab\ac\0a\f4\33\86\14\54\63\62\3f\e9\15\03\2e\ec\a0\da\02\1b\03\a0\48\be\22\21\fc\d7\49\54\00\51\6d\88\c9\36\80\03\be\61\36\ce\35\41\8b\d3\ac\40\9f\1c\ab\5c\ed\ac\4e\bb\56\33\34\9b\fa\e5\92\f1\49\4d\ff\ff\7f\20\01\00\00\00"; - }; - }, -)' - -if ! [[ $ACTUAL_HEADERS = "$EXPECTED_HEADERS" ]]; then - echo "FAIL" - exit 1 -fi - -echo "SUCCESS" diff --git a/e2e-tests/scenario-1/Cargo.toml b/e2e-tests/scenario-1/Cargo.toml index b5a60662..72f61b4d 100644 --- a/e2e-tests/scenario-1/Cargo.toml +++ b/e2e-tests/scenario-1/Cargo.toml @@ -9,3 +9,8 @@ candid = { workspace = true } ic-btc-test-utils = { workspace = true } ic-cdk = { workspace = true } serde = { workspace = true } + +[dev-dependencies] +candid = { workspace = true } +ic-btc-interface = { workspace = true } +pocket-ic = { workspace = true } diff --git a/e2e-tests/scenario-1-pocketic/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs similarity index 52% rename from e2e-tests/scenario-1-pocketic/tests/integration.rs rename to e2e-tests/scenario-1/tests/integration.rs index e1297fa2..866eed36 100644 --- a/e2e-tests/scenario-1-pocketic/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -1,15 +1,189 @@ -use e2e_scenario_1_pocketic::Setup; +use candid::{CandidType, Deserialize, Principal}; use ic_btc_interface::{ - GetBalanceRequest, GetBlockHeadersRequest, GetCurrentFeePercentilesRequest, GetUtxosRequest, - NetworkInRequest, + BlockchainInfo, CanisterArg, GetBalanceRequest, GetBlockHeadersRequest, + GetBlockHeadersResponse, GetCurrentFeePercentilesRequest, GetUtxosRequest, GetUtxosResponse, + InitConfig, Network, NetworkInRequest, }; -use pocket_ic::RejectCode; +use pocket_ic::{PocketIc, PocketIcBuilder, RejectCode, RejectResponse}; +use std::{path::PathBuf, process::Command}; -// Addresses defined in e2e-tests/scenario-1/src/main.rs +// Addresses defined in src/main.rs const ADDRESS_1: &str = "bcrt1qg4cvn305es3k8j69x06t9hf4v5yx4mxdaeazl8"; const ADDRESS_2: &str = "bcrt1qxp8ercrmfxlu0s543najcj6fe6267j97tv7rgf"; const ADDRESS_5: &str = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; +struct Setup { + pic: PocketIc, + btc_id: Principal, +} + +impl Setup { + fn new() -> Self { + let source_wasm = load_wasm("E2E_SCENARIO_1_WASM_PATH", "scenario-1"); + let btc_wasm = load_wasm("IC_BTC_CANISTER_WASM_PATH", "ic-btc-canister"); + + let pic = PocketIcBuilder::new().with_bitcoin_subnet().build(); + + let source_id = pic.create_canister(); + pic.add_cycles(source_id, 10_000_000_000_000); + pic.install_canister(source_id, source_wasm, vec![], None); + + let btc_id = pic.create_canister(); + pic.add_cycles(btc_id, 10_000_000_000_000); + pic.install_canister( + btc_id, + btc_wasm, + candid::encode_one(CanisterArg::Init(InitConfig { + stability_threshold: Some(2), + network: Some(Network::Regtest), + blocks_source: Some(source_id), + ..Default::default() + })) + .unwrap(), + None, + ); + + Self { pic, btc_id } + } + + fn tick(&self) { + self.pic.tick(); + } + + fn tick_until_main_chain_height(&self, target: u32, max_ticks: u32) { + for _ in 0..max_ticks { + self.pic.tick(); + let reached = self + .pic + .query_call( + self.btc_id, + Principal::anonymous(), + "get_blockchain_info", + candid::encode_args(()).unwrap(), + ) + .ok() + .and_then(|b| candid::decode_one::(&b).ok()) + .map(|info| info.height >= target) + .unwrap_or(false); + if reached { + return; + } + } + panic!("timed out after {max_ticks} ticks waiting for main chain height {target}"); + } + + fn get_blockchain_info(&self) -> BlockchainInfo { + let bytes = self + .pic + .query_call( + self.btc_id, + Principal::anonymous(), + "get_blockchain_info", + candid::encode_args(()).unwrap(), + ) + .expect("get_blockchain_info query failed"); + candid::decode_one(&bytes).expect("failed to decode BlockchainInfo") + } + + fn bitcoin_get_balance(&self, req: GetBalanceRequest) -> u64 { + self.update("bitcoin_get_balance", req) + } + + fn bitcoin_get_balance_query(&self, req: GetBalanceRequest) -> u64 { + self.query("bitcoin_get_balance_query", req) + } + + fn bitcoin_get_utxos(&self, req: GetUtxosRequest) -> GetUtxosResponse { + self.update("bitcoin_get_utxos", req) + } + + fn bitcoin_get_utxos_query(&self, req: GetUtxosRequest) -> GetUtxosResponse { + self.query("bitcoin_get_utxos_query", req) + } + + fn bitcoin_get_block_headers(&self, req: GetBlockHeadersRequest) -> GetBlockHeadersResponse { + self.update("bitcoin_get_block_headers", req) + } + + fn bitcoin_get_current_fee_percentiles( + &self, + req: GetCurrentFeePercentilesRequest, + ) -> Vec { + self.update("bitcoin_get_current_fee_percentiles", req) + } + + fn update_call_raw( + &self, + method: &str, + arg: impl CandidType, + ) -> Result, RejectResponse> { + self.pic.update_call( + self.btc_id, + Principal::anonymous(), + method, + candid::encode_one(arg).unwrap(), + ) + } + + fn query Deserialize<'de>>( + &self, + method: &str, + arg: impl CandidType, + ) -> T { + let bytes = self + .pic + .query_call( + self.btc_id, + Principal::anonymous(), + method, + candid::encode_one(arg).unwrap(), + ) + .unwrap_or_else(|e| panic!("{method} query failed: {e:?}")); + candid::decode_one(&bytes).unwrap_or_else(|e| panic!("decode {method} response: {e}")) + } + + fn update Deserialize<'de>>( + &self, + method: &str, + arg: impl CandidType, + ) -> T { + let bytes = self + .pic + .update_call( + self.btc_id, + Principal::anonymous(), + method, + candid::encode_one(arg).unwrap(), + ) + .unwrap_or_else(|e| panic!("{method} update call failed: {e:?}")); + candid::decode_one(&bytes).unwrap_or_else(|e| panic!("decode {method} response: {e}")) + } +} + +fn load_wasm(env_var: &str, canister_name: &str) -> Vec { + if let Ok(path) = std::env::var(env_var) { + return std::fs::read(&path) + .unwrap_or_else(|e| panic!("failed to read WASM from {path}: {e}")); + } + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("bash") + .arg(repo_root.join("scripts/build-canister.sh")) + .arg(canister_name) + .current_dir(&repo_root) + .output() + .unwrap_or_else(|e| panic!("failed to spawn build-canister.sh for {canister_name}: {e}")); + assert!( + output.status.success(), + "build-canister.sh {canister_name} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let wasm_path = repo_root.join(format!( + "target/wasm32-unknown-unknown/release/{canister_name}.wasm.gz" + )); + std::fs::read(&wasm_path) + .unwrap_or_else(|e| panic!("failed to read WASM from {wasm_path:?}: {e}")) +} + fn balance_req(address: &str, min_confirmations: Option) -> GetBalanceRequest { GetBalanceRequest { address: address.to_string(), From 58d3600d8ef1cd5a2f206e60efb96d6c72f54b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 11:48:17 +0200 Subject: [PATCH 03/25] Get stable_height from metrics as before instead of blindly ticking 100 times --- Cargo.lock | 1 + e2e-tests/scenario-1/Cargo.toml | 1 + e2e-tests/scenario-1/tests/integration.rs | 62 +++++++++++++++++++---- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b36ca5d8..871aa645 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3758,6 +3758,7 @@ dependencies = [ "ic-cdk 0.20.0", "pocket-ic", "serde", + "serde_bytes", ] [[package]] diff --git a/e2e-tests/scenario-1/Cargo.toml b/e2e-tests/scenario-1/Cargo.toml index 72f61b4d..913bc199 100644 --- a/e2e-tests/scenario-1/Cargo.toml +++ b/e2e-tests/scenario-1/Cargo.toml @@ -14,3 +14,4 @@ serde = { workspace = true } candid = { workspace = true } ic-btc-interface = { workspace = true } pocket-ic = { workspace = true } +serde_bytes = { workspace = true } diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 866eed36..fbcfbfb4 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -1,4 +1,5 @@ use candid::{CandidType, Deserialize, Principal}; +use serde_bytes::ByteBuf; use ic_btc_interface::{ BlockchainInfo, CanisterArg, GetBalanceRequest, GetBlockHeadersRequest, GetBlockHeadersResponse, GetCurrentFeePercentilesRequest, GetUtxosRequest, GetUtxosResponse, @@ -7,6 +8,21 @@ use ic_btc_interface::{ use pocket_ic::{PocketIc, PocketIcBuilder, RejectCode, RejectResponse}; use std::{path::PathBuf, process::Command}; +#[derive(CandidType)] +struct HttpRequest { + method: String, + url: String, + headers: Vec<(String, String)>, + body: ByteBuf, +} + +#[derive(CandidType, Deserialize)] +struct HttpResponse { + status_code: u16, + headers: Vec<(String, String)>, + body: ByteBuf, +} + // Addresses defined in src/main.rs const ADDRESS_1: &str = "bcrt1qg4cvn305es3k8j69x06t9hf4v5yx4mxdaeazl8"; const ADDRESS_2: &str = "bcrt1qxp8ercrmfxlu0s543najcj6fe6267j97tv7rgf"; @@ -46,10 +62,6 @@ impl Setup { Self { pic, btc_id } } - fn tick(&self) { - self.pic.tick(); - } - fn tick_until_main_chain_height(&self, target: u32, max_ticks: u32) { for _ in 0..max_ticks { self.pic.tick(); @@ -112,6 +124,40 @@ impl Setup { self.update("bitcoin_get_current_fee_percentiles", req) } + fn tick_until_stable_height(&self, target: u32, max_ticks: u32) { + for _ in 0..max_ticks { + self.pic.tick(); + if self.get_stable_height().map(|h| h >= target).unwrap_or(false) { + return; + } + } + panic!("timed out after {max_ticks} ticks waiting for stable height {target}"); + } + + fn get_stable_height(&self) -> Option { + let request = HttpRequest { + method: "GET".to_string(), + url: "/metrics".to_string(), + headers: vec![], + body: ByteBuf::new(), + }; + let bytes = self + .pic + .query_call( + self.btc_id, + Principal::anonymous(), + "http_request", + candid::encode_one(request).unwrap(), + ) + .ok()?; + let response = candid::decode_one::(&bytes).ok()?; + let body = String::from_utf8(response.body.into_vec()).ok()?; + body.lines() + .find(|line| line.starts_with("stable_height ")) + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|s| s.parse::().ok()) + } + fn update_call_raw( &self, method: &str, @@ -210,11 +256,9 @@ fn scenario_1() { let info = setup.get_blockchain_info(); assert_eq!(info.height, 5, "expected blockchain height 5, got {}", info.height); - // Tick more to let stable-block processing complete (blocks 1–3 are stable with - // stability_threshold=2 once main_chain_height=5). - for _ in 0..100 { - setup.tick(); - } + // Wait for stable-block processing to complete. With stability_threshold=2 and + // main_chain_height=5, blocks 1–3 should become stable. + setup.tick_until_stable_height(3, 200); // ADDRESS_1 has no balance: it transferred everything to ADDRESS_2 in block 2. assert_eq!(setup.bitcoin_get_balance(balance_req(ADDRESS_1, None)), 0); From 2abba8ee7990350da7ee3e142b81c4729a3a382d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 11:53:15 +0200 Subject: [PATCH 04/25] Remove redundant candid dev-dependency in scenario-1 candid is already listed under [dependencies] so the entry under [dev-dependencies] is a no-op. Co-Authored-By: Claude Sonnet 4.6 --- e2e-tests/scenario-1/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e-tests/scenario-1/Cargo.toml b/e2e-tests/scenario-1/Cargo.toml index 913bc199..e1855fec 100644 --- a/e2e-tests/scenario-1/Cargo.toml +++ b/e2e-tests/scenario-1/Cargo.toml @@ -11,7 +11,6 @@ ic-cdk = { workspace = true } serde = { workspace = true } [dev-dependencies] -candid = { workspace = true } ic-btc-interface = { workspace = true } pocket-ic = { workspace = true } serde_bytes = { workspace = true } From aa942affbbebd53e5a1214d182f2304ac580c115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 11:54:01 +0200 Subject: [PATCH 05/25] Store source_id in Setup Retaining the block-source canister's principal in the Setup struct costs nothing and avoids having to refactor if a future test needs to inspect or interact with that canister. Co-Authored-By: Claude Sonnet 4.6 --- e2e-tests/scenario-1/tests/integration.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index fbcfbfb4..84c22064 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -30,6 +30,8 @@ const ADDRESS_5: &str = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; struct Setup { pic: PocketIc, + #[allow(dead_code)] + source_id: Principal, btc_id: Principal, } @@ -59,7 +61,11 @@ impl Setup { None, ); - Self { pic, btc_id } + Self { + pic, + source_id, + btc_id, + } } fn tick_until_main_chain_height(&self, target: u32, max_ticks: u32) { From 811dc817e6c52d299c53623308b44a9d195019de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 11:54:17 +0200 Subject: [PATCH 06/25] Panic with error body on non-200 metrics response Previously a 500 from the metrics endpoint caused get_stable_height to silently return None, making tick_until_stable_height time out with a generic message. Assert 200 explicitly so the canister's error text is surfaced immediately. Co-Authored-By: Claude Sonnet 4.6 --- e2e-tests/scenario-1/tests/integration.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 84c22064..56264f60 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -157,6 +157,12 @@ impl Setup { ) .ok()?; let response = candid::decode_one::(&bytes).ok()?; + assert_eq!( + response.status_code, 200, + "metrics endpoint returned {}: {}", + response.status_code, + String::from_utf8_lossy(&response.body) + ); let body = String::from_utf8(response.body.into_vec()).ok()?; body.lines() .find(|line| line.starts_with("stable_height ")) From c935b0f054e8d512b2fdc6562692f485771c863d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 11:54:34 +0200 Subject: [PATCH 07/25] Parse stable_height metric as f64 before casting to u32 The metric is encoded via encode_gauge which takes an f64. The current ic-metrics-encoder serialises 3.0 as "3", so parse::() works today, but parsing as f64 first makes the code robust against any future encoder that emits "3.0". Co-Authored-By: Claude Sonnet 4.6 --- e2e-tests/scenario-1/tests/integration.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 56264f60..bb743eb8 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -164,10 +164,13 @@ impl Setup { String::from_utf8_lossy(&response.body) ); let body = String::from_utf8(response.body.into_vec()).ok()?; + // The metric is encoded as f64 but always a whole number; parse as f64 first + // so this survives any encoder change that emits "3.0" instead of "3". body.lines() .find(|line| line.starts_with("stable_height ")) .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|s| s.parse::().ok()) + .and_then(|s| s.parse::().ok()) + .map(|v| v as u32) } fn update_call_raw( From 0fe224773bca3a308e7ee8fe8650152fb4810cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 11:54:52 +0200 Subject: [PATCH 08/25] Document the rationale behind max_ticks values 500 ticks for main chain height and 200 for stable height were magic numbers. Add comments explaining why each ceiling is generous enough in practice. Co-Authored-By: Claude Sonnet 4.6 --- e2e-tests/scenario-1/tests/integration.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index bb743eb8..ae939f17 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -265,14 +265,17 @@ fn utxos_req(address: &str) -> GetUtxosRequest { fn scenario_1() { let setup = Setup::new(); - // Wait until all 5 blocks have been ingested. + // Wait until all 5 blocks have been ingested. The scenario-1 canister serves 7 + // GetSuccessors responses (one per heartbeat call); 500 ticks is a generous ceiling. setup.tick_until_main_chain_height(5, 500); let info = setup.get_blockchain_info(); assert_eq!(info.height, 5, "expected blockchain height 5, got {}", info.height); // Wait for stable-block processing to complete. With stability_threshold=2 and - // main_chain_height=5, blocks 1–3 should become stable. + // main_chain_height=5, blocks 1–3 should become stable. Stable ingestion happens + // incrementally across heartbeats after the main chain advances, so a few dozen + // ticks typically suffice; 200 is a generous ceiling. setup.tick_until_stable_height(3, 200); // ADDRESS_1 has no balance: it transferred everything to ADDRESS_2 in block 2. From 8257a347282eed405809193d200ed2ae58e4f69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 12:23:40 +0200 Subject: [PATCH 09/25] Run scenario-1 via cargo test in CI The previous job invoked the deleted scenario-1.sh script. Mirror the e2e-cdk-bitcoin-canister job: install PocketIC, download the ic-btc-canister WASM artifact, build the scenario-1 source canister, and run `cargo test -p scenario-1` with the matching env vars. Exclude scenario-1 from cargo-tests so it is not run twice. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/workflow.yml | 36 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 4e5b098d..9f1ac932 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -95,7 +95,7 @@ jobs: - name: Run Tests shell: bash run: | - cargo test --release --all-targets --workspace --exclude benchmarks --exclude e2e-cdk-bitcoin-canister -- --color always + cargo test --release --all-targets --workspace --exclude benchmarks --exclude e2e-cdk-bitcoin-canister --exclude scenario-1 -- --color always env: RUST_BACKTRACE: 1 @@ -155,7 +155,7 @@ jobs: e2e-scenario-1: runs-on: ubuntu-latest - needs: [cargo-build, canister-build-reproducibility] + needs: canister-build-reproducibility steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -167,26 +167,34 @@ jobs: target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-1 + - name: Install Rust + run: | + rustup update ${RUST_VERSION} --no-self-update + rustup default ${RUST_VERSION} + rustup target add wasm32-unknown-unknown + + - name: Install PocketIC + run: | + wget https://github.com/dfinity/pocketic/releases/download/$POCKET_IC_SERVER_VERSION/pocket-ic-x86_64-linux.gz + gzip -d pocket-ic-x86_64-linux.gz + mv pocket-ic-x86_64-linux pocket-ic + chmod +x pocket-ic + mv pocket-ic ./canister/ + - name: Download WASMs uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: wasms path: ./wasms - - name: Install Rust - run: | - rustup update ${{ matrix.rust }} --no-self-update - rustup default ${{ matrix.rust }} - rustup target add wasm32-unknown-unknown - - - name: Install dfx - uses: dfinity/setup-dfx@e50c04f104ee4285ec010f10609483cf41e4d365 # main - with: - dfx-version: $DFX_VERSION + - name: Build scenario-1 source canister WASM + run: bash scripts/build-canister.sh scenario-1 - name: Run scenario 1 - run: | - bash e2e-tests/scenario-1.sh + run: cargo test --release -p scenario-1 + env: + IC_BTC_CANISTER_WASM_PATH: ${{ github.workspace }}/wasms/ic-btc-canister.wasm.gz + E2E_SCENARIO_1_WASM_PATH: ${{ github.workspace }}/target/wasm32-unknown-unknown/release/scenario-1.wasm.gz e2e-scenario-2: runs-on: ubuntu-latest From 8ccec7fca1eb6b5d3006076a24ee8502be56a009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 12:25:15 +0200 Subject: [PATCH 10/25] Drop unused source_id field from Setup The field was stored but only used during install_canister; nothing reads it back. Removing it eliminates the #[allow(dead_code)] escape hatch. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/integration.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index ae939f17..b963ba2a 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -30,8 +30,6 @@ const ADDRESS_5: &str = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; struct Setup { pic: PocketIc, - #[allow(dead_code)] - source_id: Principal, btc_id: Principal, } @@ -61,11 +59,7 @@ impl Setup { None, ); - Self { - pic, - source_id, - btc_id, - } + Self { pic, btc_id } } fn tick_until_main_chain_height(&self, target: u32, max_ticks: u32) { From 725dca1c5a77f011242de5dc0c59f475ce82b7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 12:29:42 +0200 Subject: [PATCH 11/25] Move scenario-1 address constants into a shared lib The integration test redeclared ADDRESS_1/2/5 alongside the canister's own const definitions, which would silently drift if the addresses ever changed. Add a small src/lib.rs that exposes them as pub const so both main.rs and the integration test import from the same source. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/src/lib.rs | 6 ++++++ e2e-tests/scenario-1/src/main.rs | 8 +------- e2e-tests/scenario-1/tests/integration.rs | 6 +----- 3 files changed, 8 insertions(+), 12 deletions(-) create mode 100644 e2e-tests/scenario-1/src/lib.rs diff --git a/e2e-tests/scenario-1/src/lib.rs b/e2e-tests/scenario-1/src/lib.rs new file mode 100644 index 00000000..17b3f830 --- /dev/null +++ b/e2e-tests/scenario-1/src/lib.rs @@ -0,0 +1,6 @@ +pub const MINER_ADDRESS: &str = "mwSSBD3NCriNXNMgd6dr2N2rxX9M9zXqrp"; +pub const ADDRESS_1: &str = "bcrt1qg4cvn305es3k8j69x06t9hf4v5yx4mxdaeazl8"; +pub const ADDRESS_2: &str = "bcrt1qxp8ercrmfxlu0s543najcj6fe6267j97tv7rgf"; +pub const ADDRESS_3: &str = "bcrt1qp045tvzkxx0292645rxem9eryc7jpwsk3dy60h"; +pub const ADDRESS_4: &str = "bcrt1qjft8fhexv4znxu22hed7gxtpy2wazjn0x079mn"; +pub const ADDRESS_5: &str = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; diff --git a/e2e-tests/scenario-1/src/main.rs b/e2e-tests/scenario-1/src/main.rs index ae6423cc..388fd259 100644 --- a/e2e-tests/scenario-1/src/main.rs +++ b/e2e-tests/scenario-1/src/main.rs @@ -5,6 +5,7 @@ use bitcoin::{ use candid::CandidType; use ic_btc_test_utils::{BlockBuilder, TransactionBuilder}; use ic_cdk::{init, update}; +use scenario_1::{ADDRESS_1, ADDRESS_2, ADDRESS_3, ADDRESS_4, ADDRESS_5, MINER_ADDRESS}; use serde::{Deserialize, Serialize}; use std::cell::{Cell, RefCell}; use std::str::FromStr; @@ -13,13 +14,6 @@ type BlockBlob = Vec; type BlockHeaderBlob = Vec; type BlockHash = Vec; -const MINER_ADDRESS: &str = "mwSSBD3NCriNXNMgd6dr2N2rxX9M9zXqrp"; -const ADDRESS_1: &str = "bcrt1qg4cvn305es3k8j69x06t9hf4v5yx4mxdaeazl8"; -const ADDRESS_2: &str = "bcrt1qxp8ercrmfxlu0s543najcj6fe6267j97tv7rgf"; -const ADDRESS_3: &str = "bcrt1qp045tvzkxx0292645rxem9eryc7jpwsk3dy60h"; -const ADDRESS_4: &str = "bcrt1qjft8fhexv4znxu22hed7gxtpy2wazjn0x079mn"; -const ADDRESS_5: &str = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; - #[derive(CandidType, Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] enum Network { #[serde(rename = "mainnet")] diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index b963ba2a..1fe5aed5 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -6,6 +6,7 @@ use ic_btc_interface::{ InitConfig, Network, NetworkInRequest, }; use pocket_ic::{PocketIc, PocketIcBuilder, RejectCode, RejectResponse}; +use scenario_1::{ADDRESS_1, ADDRESS_2, ADDRESS_5}; use std::{path::PathBuf, process::Command}; #[derive(CandidType)] @@ -23,11 +24,6 @@ struct HttpResponse { body: ByteBuf, } -// Addresses defined in src/main.rs -const ADDRESS_1: &str = "bcrt1qg4cvn305es3k8j69x06t9hf4v5yx4mxdaeazl8"; -const ADDRESS_2: &str = "bcrt1qxp8ercrmfxlu0s543najcj6fe6267j97tv7rgf"; -const ADDRESS_5: &str = "bcrt1qenhfslne5vdqld0djs0h0tfw225tkkzzc60exh"; - struct Setup { pic: PocketIc, btc_id: Principal, From 29b99e7a55669301bc5a3c1db8822ab37ecb6dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 12:36:53 +0200 Subject: [PATCH 12/25] Run scenario-1 test on an application subnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bitcoin canister never invokes the IC Bitcoin adapter system API in this test — it only makes inter-canister calls to its configured blocks_source. with_bitcoin_subnet() implied an adapter dependency the test does not have; with_application_subnet() reflects the actual topology and matches how the canister is used here. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 1fe5aed5..10aa91b3 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -34,7 +34,7 @@ impl Setup { let source_wasm = load_wasm("E2E_SCENARIO_1_WASM_PATH", "scenario-1"); let btc_wasm = load_wasm("IC_BTC_CANISTER_WASM_PATH", "ic-btc-canister"); - let pic = PocketIcBuilder::new().with_bitcoin_subnet().build(); + let pic = PocketIcBuilder::new().with_application_subnet().build(); let source_id = pic.create_canister(); pic.add_cycles(source_id, 10_000_000_000_000); From d53ae2f78fe96f06cbce2224f0f3ec1352b0984b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 12:51:53 +0200 Subject: [PATCH 13/25] Accept labeled stable_height metric If labels are ever added to the metric, the prefix match would silently miss the line and the tick loop would time out with a confusing error rather than report the format change. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/integration.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 10aa91b3..48389ac8 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -156,8 +156,12 @@ impl Setup { let body = String::from_utf8(response.body.into_vec()).ok()?; // The metric is encoded as f64 but always a whole number; parse as f64 first // so this survives any encoder change that emits "3.0" instead of "3". + // Accept both unlabeled ("stable_height N") and labeled ("stable_height{...} N") + // forms so a future label addition doesn't silently break the match. body.lines() - .find(|line| line.starts_with("stable_height ")) + .find(|line| { + line.starts_with("stable_height ") || line.starts_with("stable_height{") + }) .and_then(|line| line.split_whitespace().nth(1)) .and_then(|s| s.parse::().ok()) .map(|v| v as u32) From 757c2953bf2fbc055980edc49e14058cd069813a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 12:53:00 +0200 Subject: [PATCH 14/25] Surface errors in tick_until_main_chain_height The previous .ok().and_then(...) chain silently swallowed both transport and decode errors, so a renamed method or schema change would manifest as a timeout rather than report the underlying failure. Delegating to get_blockchain_info means call/decode failures panic immediately with a useful message. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/integration.rs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 48389ac8..20a9d097 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -61,19 +61,7 @@ impl Setup { fn tick_until_main_chain_height(&self, target: u32, max_ticks: u32) { for _ in 0..max_ticks { self.pic.tick(); - let reached = self - .pic - .query_call( - self.btc_id, - Principal::anonymous(), - "get_blockchain_info", - candid::encode_args(()).unwrap(), - ) - .ok() - .and_then(|b| candid::decode_one::(&b).ok()) - .map(|info| info.height >= target) - .unwrap_or(false); - if reached { + if self.get_blockchain_info().height >= target { return; } } From b81e4f979550e1190cb47fa3627054422a50a935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 12:57:04 +0200 Subject: [PATCH 15/25] Sort use statements alphabetically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is what rustfmt would produce for the import block — move serde_bytes after scenario_1 so the order matches the rest of the workspace. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 20a9d097..56c67769 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -1,5 +1,4 @@ use candid::{CandidType, Deserialize, Principal}; -use serde_bytes::ByteBuf; use ic_btc_interface::{ BlockchainInfo, CanisterArg, GetBalanceRequest, GetBlockHeadersRequest, GetBlockHeadersResponse, GetCurrentFeePercentilesRequest, GetUtxosRequest, GetUtxosResponse, @@ -7,6 +6,7 @@ use ic_btc_interface::{ }; use pocket_ic::{PocketIc, PocketIcBuilder, RejectCode, RejectResponse}; use scenario_1::{ADDRESS_1, ADDRESS_2, ADDRESS_5}; +use serde_bytes::ByteBuf; use std::{path::PathBuf, process::Command}; #[derive(CandidType)] From 741c7861d6b047915a03221c72c35ad891702876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 12:59:01 +0200 Subject: [PATCH 16/25] Note that fee-percentile calls are not asserted Make it explicit that these calls match the original scenario-1.sh intent of exercising the endpoint for profiling, so a future reader doesn't add an unrelated assertion thinking the check was forgotten. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/integration.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 56c67769..9410f5be 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -294,7 +294,9 @@ fn scenario_1() { assert_eq!(setup.bitcoin_get_balance(balance_req(ADDRESS_5, None)), 5_000_000_000); assert_eq!(setup.bitcoin_get_balance_query(balance_req(ADDRESS_5, None)), 5_000_000_000); - // Fee percentiles smoke test. + // Fee percentiles smoke test. The result is intentionally not asserted; these + // calls exist only to exercise the endpoint for profiling, matching the + // behaviour of the original scenario-1.sh script. let fee_req = || GetCurrentFeePercentilesRequest { network: NetworkInRequest::Regtest, }; From 220690c49d8dcc308aca2999e8f289e7f24d1027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 13 May 2026 13:00:48 +0200 Subject: [PATCH 17/25] rustfmt --- e2e-tests/scenario-1/tests/integration.rs | 55 ++++++++++++++++++----- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 9410f5be..1a3c8a89 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -111,7 +111,11 @@ impl Setup { fn tick_until_stable_height(&self, target: u32, max_ticks: u32) { for _ in 0..max_ticks { self.pic.tick(); - if self.get_stable_height().map(|h| h >= target).unwrap_or(false) { + if self + .get_stable_height() + .map(|h| h >= target) + .unwrap_or(false) + { return; } } @@ -136,7 +140,8 @@ impl Setup { .ok()?; let response = candid::decode_one::(&bytes).ok()?; assert_eq!( - response.status_code, 200, + response.status_code, + 200, "metrics endpoint returned {}: {}", response.status_code, String::from_utf8_lossy(&response.body) @@ -147,9 +152,7 @@ impl Setup { // Accept both unlabeled ("stable_height N") and labeled ("stable_height{...} N") // forms so a future label addition doesn't silently break the match. body.lines() - .find(|line| { - line.starts_with("stable_height ") || line.starts_with("stable_height{") - }) + .find(|line| line.starts_with("stable_height ") || line.starts_with("stable_height{")) .and_then(|line| line.split_whitespace().nth(1)) .and_then(|s| s.parse::().ok()) .map(|v| v as u32) @@ -252,7 +255,11 @@ fn scenario_1() { setup.tick_until_main_chain_height(5, 500); let info = setup.get_blockchain_info(); - assert_eq!(info.height, 5, "expected blockchain height 5, got {}", info.height); + assert_eq!( + info.height, 5, + "expected blockchain height 5, got {}", + info.height + ); // Wait for stable-block processing to complete. With stability_threshold=2 and // main_chain_height=5, blocks 1–3 should become stable. Stable ingestion happens @@ -262,7 +269,10 @@ fn scenario_1() { // ADDRESS_1 has no balance: it transferred everything to ADDRESS_2 in block 2. assert_eq!(setup.bitcoin_get_balance(balance_req(ADDRESS_1, None)), 0); - assert_eq!(setup.bitcoin_get_balance_query(balance_req(ADDRESS_1, None)), 0); + assert_eq!( + setup.bitcoin_get_balance_query(balance_req(ADDRESS_1, None)), + 0 + ); // ADDRESS_2 with min_confirmations=2: block 5's spend is excluded (only 1 confirmation at // tip), so it still shows the 50 BTC received in block 2. @@ -273,11 +283,26 @@ fn scenario_1() { // ADDRESS_2 UTXOs without filter: block 5 is included so all are spent. assert_eq!(setup.bitcoin_get_utxos(utxos_req(ADDRESS_2)).utxos.len(), 0); - assert_eq!(setup.bitcoin_get_utxos_query(utxos_req(ADDRESS_2)).utxos.len(), 0); + assert_eq!( + setup + .bitcoin_get_utxos_query(utxos_req(ADDRESS_2)) + .utxos + .len(), + 0 + ); // ADDRESS_5 has 10k UTXOs (received in block 5), but responses are capped at 1000. - assert_eq!(setup.bitcoin_get_utxos(utxos_req(ADDRESS_5)).utxos.len(), 1000); - assert_eq!(setup.bitcoin_get_utxos_query(utxos_req(ADDRESS_5)).utxos.len(), 1000); + assert_eq!( + setup.bitcoin_get_utxos(utxos_req(ADDRESS_5)).utxos.len(), + 1000 + ); + assert_eq!( + setup + .bitcoin_get_utxos_query(utxos_req(ADDRESS_5)) + .utxos + .len(), + 1000 + ); // Calling query-only methods as replicated (update) calls must be rejected. let err = setup @@ -291,8 +316,14 @@ fn scenario_1() { assert_eq!(err.reject_code, RejectCode::CanisterReject); // ADDRESS_5 balance. - assert_eq!(setup.bitcoin_get_balance(balance_req(ADDRESS_5, None)), 5_000_000_000); - assert_eq!(setup.bitcoin_get_balance_query(balance_req(ADDRESS_5, None)), 5_000_000_000); + assert_eq!( + setup.bitcoin_get_balance(balance_req(ADDRESS_5, None)), + 5_000_000_000 + ); + assert_eq!( + setup.bitcoin_get_balance_query(balance_req(ADDRESS_5, None)), + 5_000_000_000 + ); // Fee percentiles smoke test. The result is intentionally not asserted; these // calls exist only to exercise the endpoint for profiling, matching the From 43f90340434de770b975d7c419afa964c8883b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 15 May 2026 07:13:24 +0000 Subject: [PATCH 18/25] Reuse HttpRequest/HttpResponse from ic-btc-canister Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + e2e-tests/scenario-1/Cargo.toml | 1 + e2e-tests/scenario-1/tests/integration.rs | 16 +--------------- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 871aa645..fdbfca72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3753,6 +3753,7 @@ version = "0.1.0" dependencies = [ "bitcoin-dogecoin", "candid", + "ic-btc-canister", "ic-btc-interface", "ic-btc-test-utils", "ic-cdk 0.20.0", diff --git a/e2e-tests/scenario-1/Cargo.toml b/e2e-tests/scenario-1/Cargo.toml index e1855fec..68dc7095 100644 --- a/e2e-tests/scenario-1/Cargo.toml +++ b/e2e-tests/scenario-1/Cargo.toml @@ -11,6 +11,7 @@ ic-cdk = { workspace = true } serde = { workspace = true } [dev-dependencies] +ic-btc-canister = { workspace = true } ic-btc-interface = { workspace = true } pocket-ic = { workspace = true } serde_bytes = { workspace = true } diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 1a3c8a89..309519a3 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -1,4 +1,5 @@ use candid::{CandidType, Deserialize, Principal}; +use ic_btc_canister::types::{HttpRequest, HttpResponse}; use ic_btc_interface::{ BlockchainInfo, CanisterArg, GetBalanceRequest, GetBlockHeadersRequest, GetBlockHeadersResponse, GetCurrentFeePercentilesRequest, GetUtxosRequest, GetUtxosResponse, @@ -9,21 +10,6 @@ use scenario_1::{ADDRESS_1, ADDRESS_2, ADDRESS_5}; use serde_bytes::ByteBuf; use std::{path::PathBuf, process::Command}; -#[derive(CandidType)] -struct HttpRequest { - method: String, - url: String, - headers: Vec<(String, String)>, - body: ByteBuf, -} - -#[derive(CandidType, Deserialize)] -struct HttpResponse { - status_code: u16, - headers: Vec<(String, String)>, - body: ByteBuf, -} - struct Setup { pic: PocketIc, btc_id: Principal, From 1869e1c71acaa9e723eaba71e15dc787e64b2906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Mon, 18 May 2026 12:59:05 +0000 Subject: [PATCH 19/25] Install bitcoin canister on a bitcoin subnet The bitcoin canister runs on a bitcoin (system) subnet in production. Use `with_bitcoin_subnet()` so the test reflects that; keep the source canister on a separate application subnet since it is a test fixture, not part of the canister under test. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/integration.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/integration.rs index 309519a3..16ef3441 100644 --- a/e2e-tests/scenario-1/tests/integration.rs +++ b/e2e-tests/scenario-1/tests/integration.rs @@ -20,13 +20,21 @@ impl Setup { let source_wasm = load_wasm("E2E_SCENARIO_1_WASM_PATH", "scenario-1"); let btc_wasm = load_wasm("IC_BTC_CANISTER_WASM_PATH", "ic-btc-canister"); - let pic = PocketIcBuilder::new().with_application_subnet().build(); - - let source_id = pic.create_canister(); + // Install the bitcoin canister on a bitcoin subnet to match production; the + // source canister is a test fixture and lives on a separate application subnet. + let pic = PocketIcBuilder::new() + .with_bitcoin_subnet() + .with_application_subnet() + .build(); + let topology = pic.topology(); + let bitcoin_subnet = topology.get_bitcoin().expect("bitcoin subnet not present"); + let app_subnet = topology.get_app_subnets()[0]; + + let source_id = pic.create_canister_on_subnet(None, None, app_subnet); pic.add_cycles(source_id, 10_000_000_000_000); pic.install_canister(source_id, source_wasm, vec![], None); - let btc_id = pic.create_canister(); + let btc_id = pic.create_canister_on_subnet(None, None, bitcoin_subnet); pic.add_cycles(btc_id, 10_000_000_000_000); pic.install_canister( btc_id, From 4aa4d0c411732fc12ee913894bee6a0f94c42eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Mon, 18 May 2026 13:03:45 +0000 Subject: [PATCH 20/25] Rename e2e-tests/scenario-1/tests/integration.rs to tests.rs Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/{integration.rs => tests.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename e2e-tests/scenario-1/tests/{integration.rs => tests.rs} (100%) diff --git a/e2e-tests/scenario-1/tests/integration.rs b/e2e-tests/scenario-1/tests/tests.rs similarity index 100% rename from e2e-tests/scenario-1/tests/integration.rs rename to e2e-tests/scenario-1/tests/tests.rs From ade73f95c2500c0c632fed2813b7e54ff7bed052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Mon, 18 May 2026 13:12:07 +0000 Subject: [PATCH 21/25] Drop the unchecked bitcoin_get_current_fee_percentiles calls The two unchecked calls and the "for profiling purposes" comment were inherited from scenario-1.sh, which itself inherited them from PR #50 (Sep 2022). That PR recorded each call's instruction count into e2e-tests/instructions_count.txt as a hand-maintained profiling baseline. That baseline file has since been deleted, and the endpoint is now covered with much higher rigour by the canbench benchmark `bitcoin_get_current_fee_percentiles` (visible in every CI run via canbench_results.yml) and by dedicated unit tests in canister/src/api/fee_percentiles.rs (including caching behaviour). The two unchecked e2e calls produce no signal today. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/tests.rs | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/e2e-tests/scenario-1/tests/tests.rs b/e2e-tests/scenario-1/tests/tests.rs index 16ef3441..e700438f 100644 --- a/e2e-tests/scenario-1/tests/tests.rs +++ b/e2e-tests/scenario-1/tests/tests.rs @@ -2,8 +2,8 @@ use candid::{CandidType, Deserialize, Principal}; use ic_btc_canister::types::{HttpRequest, HttpResponse}; use ic_btc_interface::{ BlockchainInfo, CanisterArg, GetBalanceRequest, GetBlockHeadersRequest, - GetBlockHeadersResponse, GetCurrentFeePercentilesRequest, GetUtxosRequest, GetUtxosResponse, - InitConfig, Network, NetworkInRequest, + GetBlockHeadersResponse, GetUtxosRequest, GetUtxosResponse, InitConfig, Network, + NetworkInRequest, }; use pocket_ic::{PocketIc, PocketIcBuilder, RejectCode, RejectResponse}; use scenario_1::{ADDRESS_1, ADDRESS_2, ADDRESS_5}; @@ -95,13 +95,6 @@ impl Setup { self.update("bitcoin_get_block_headers", req) } - fn bitcoin_get_current_fee_percentiles( - &self, - req: GetCurrentFeePercentilesRequest, - ) -> Vec { - self.update("bitcoin_get_current_fee_percentiles", req) - } - fn tick_until_stable_height(&self, target: u32, max_ticks: u32) { for _ in 0..max_ticks { self.pic.tick(); @@ -319,15 +312,6 @@ fn scenario_1() { 5_000_000_000 ); - // Fee percentiles smoke test. The result is intentionally not asserted; these - // calls exist only to exercise the endpoint for profiling, matching the - // behaviour of the original scenario-1.sh script. - let fee_req = || GetCurrentFeePercentilesRequest { - network: NetworkInRequest::Regtest, - }; - setup.bitcoin_get_current_fee_percentiles(fee_req()); - setup.bitcoin_get_current_fee_percentiles(fee_req()); - // Verify block headers. The scenario-1 canister chains 5 blocks onto the genesis block, // so get_block_headers returns 6 headers (genesis + blocks 1–5). let headers_resp = setup.bitcoin_get_block_headers(GetBlockHeadersRequest { From 6c35d3fc5160fd8886014d95c4d941cec5902183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Mon, 18 May 2026 13:14:44 +0000 Subject: [PATCH 22/25] Use dfinity/pocketic action to install PocketIC in scenario-1 job The action handles OS/arch detection, downloads the requested version, and exports POCKET_IC_BIN so subsequent steps pick it up automatically. Pinned to the same SHA used by dfinity/sol-rpc-canister. Scoped to the e2e-scenario-1 job; the other manual install blocks are pre-existing and out of scope for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/workflow.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 9f1ac932..ef65c0d7 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -173,13 +173,10 @@ jobs: rustup default ${RUST_VERSION} rustup target add wasm32-unknown-unknown - - name: Install PocketIC - run: | - wget https://github.com/dfinity/pocketic/releases/download/$POCKET_IC_SERVER_VERSION/pocket-ic-x86_64-linux.gz - gzip -d pocket-ic-x86_64-linux.gz - mv pocket-ic-x86_64-linux pocket-ic - chmod +x pocket-ic - mv pocket-ic ./canister/ + - name: Install PocketIC server + uses: dfinity/pocketic@20c33db1aa87cc6ece50857ac632c37acf5e0322 # main + with: + pocket-ic-server-version: ${{ env.POCKET_IC_SERVER_VERSION }} - name: Download WASMs uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 From e226323b63f81717043752e3cc4ed4cf623a3325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Mon, 18 May 2026 13:19:35 +0000 Subject: [PATCH 23/25] Stop swallowing errors in get_stable_height stable_height is unconditionally encoded as a gauge in the /metrics output (see canister/src/api/metrics.rs), so every step in get_stable_height must succeed: the http_request query, the candid decode, the UTF-8 conversion, and locating + parsing the metric line. The previous .ok()? chain silently mapped genuine infrastructure failures to "value not at target yet", which would have surfaced as an opaque "timed out waiting for stable height" timeout rather than a precise panic. Replace .ok()? with .expect(...) at each step and change the return type from Option to u32. tick_until_stable_height simplifies to a direct numeric comparison. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/tests.rs | 31 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/e2e-tests/scenario-1/tests/tests.rs b/e2e-tests/scenario-1/tests/tests.rs index e700438f..4d566435 100644 --- a/e2e-tests/scenario-1/tests/tests.rs +++ b/e2e-tests/scenario-1/tests/tests.rs @@ -98,18 +98,14 @@ impl Setup { fn tick_until_stable_height(&self, target: u32, max_ticks: u32) { for _ in 0..max_ticks { self.pic.tick(); - if self - .get_stable_height() - .map(|h| h >= target) - .unwrap_or(false) - { + if self.get_stable_height() >= target { return; } } panic!("timed out after {max_ticks} ticks waiting for stable height {target}"); } - fn get_stable_height(&self) -> Option { + fn get_stable_height(&self) -> u32 { let request = HttpRequest { method: "GET".to_string(), url: "/metrics".to_string(), @@ -124,8 +120,9 @@ impl Setup { "http_request", candid::encode_one(request).unwrap(), ) - .ok()?; - let response = candid::decode_one::(&bytes).ok()?; + .expect("http_request /metrics query failed"); + let response: HttpResponse = + candid::decode_one(&bytes).expect("failed to decode /metrics response"); assert_eq!( response.status_code, 200, @@ -133,16 +130,24 @@ impl Setup { response.status_code, String::from_utf8_lossy(&response.body) ); - let body = String::from_utf8(response.body.into_vec()).ok()?; + let body = String::from_utf8(response.body.into_vec()) + .expect("/metrics body is not valid UTF-8"); // The metric is encoded as f64 but always a whole number; parse as f64 first // so this survives any encoder change that emits "3.0" instead of "3". // Accept both unlabeled ("stable_height N") and labeled ("stable_height{...} N") // forms so a future label addition doesn't silently break the match. - body.lines() + let line = body + .lines() .find(|line| line.starts_with("stable_height ") || line.starts_with("stable_height{")) - .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|s| s.parse::().ok()) - .map(|v| v as u32) + .expect("stable_height metric not found in /metrics output"); + let value = line + .split_whitespace() + .nth(1) + .expect("stable_height line has no value field"); + value + .parse::() + .unwrap_or_else(|e| panic!("failed to parse stable_height value {value:?}: {e}")) + as u32 } fn update_call_raw( From ec2b3043f6adc90a796bfec043bc93a0e160dbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Mon, 18 May 2026 13:27:50 +0000 Subject: [PATCH 24/25] Clippy --- e2e-tests/scenario-1/tests/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-tests/scenario-1/tests/tests.rs b/e2e-tests/scenario-1/tests/tests.rs index 4d566435..27baf0f9 100644 --- a/e2e-tests/scenario-1/tests/tests.rs +++ b/e2e-tests/scenario-1/tests/tests.rs @@ -130,8 +130,8 @@ impl Setup { response.status_code, String::from_utf8_lossy(&response.body) ); - let body = String::from_utf8(response.body.into_vec()) - .expect("/metrics body is not valid UTF-8"); + let body = + String::from_utf8(response.body.into_vec()).expect("/metrics body is not valid UTF-8"); // The metric is encoded as f64 but always a whole number; parse as f64 first // so this survives any encoder change that emits "3.0" instead of "3". // Accept both unlabeled ("stable_height N") and labeled ("stable_height{...} N") From 1a60f6a998a1730710205866a775b3bfc4bf1327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Mon, 18 May 2026 20:24:28 +0000 Subject: [PATCH 25/25] Co-locate source canister with bitcoin canister on the bitcoin subnet The source canister stands in for the bitcoin adapter, which in production is a node-level service co-located with the bitcoin canister rather than a separate canister on another subnet. Putting it on an application subnet introduced topology that does not exist in production; install both on the bitcoin subnet instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e-tests/scenario-1/tests/tests.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/e2e-tests/scenario-1/tests/tests.rs b/e2e-tests/scenario-1/tests/tests.rs index 27baf0f9..47d8c637 100644 --- a/e2e-tests/scenario-1/tests/tests.rs +++ b/e2e-tests/scenario-1/tests/tests.rs @@ -20,17 +20,16 @@ impl Setup { let source_wasm = load_wasm("E2E_SCENARIO_1_WASM_PATH", "scenario-1"); let btc_wasm = load_wasm("IC_BTC_CANISTER_WASM_PATH", "ic-btc-canister"); - // Install the bitcoin canister on a bitcoin subnet to match production; the - // source canister is a test fixture and lives on a separate application subnet. - let pic = PocketIcBuilder::new() - .with_bitcoin_subnet() - .with_application_subnet() - .build(); - let topology = pic.topology(); - let bitcoin_subnet = topology.get_bitcoin().expect("bitcoin subnet not present"); - let app_subnet = topology.get_app_subnets()[0]; - - let source_id = pic.create_canister_on_subnet(None, None, app_subnet); + // Install both canisters on a bitcoin subnet to match production. The source + // canister stands in for the bitcoin adapter, which in production is a + // node-level service co-located with the bitcoin canister. + let pic = PocketIcBuilder::new().with_bitcoin_subnet().build(); + let bitcoin_subnet = pic + .topology() + .get_bitcoin() + .expect("bitcoin subnet not present"); + + let source_id = pic.create_canister_on_subnet(None, None, bitcoin_subnet); pic.add_cycles(source_id, 10_000_000_000_000); pic.install_canister(source_id, source_wasm, vec![], None);