diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 4e5b098d..ef65c0d7 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,31 @@ 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 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 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 diff --git a/Cargo.lock b/Cargo.lock index 0b4824f3..fdbfca72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3753,9 +3753,13 @@ version = "0.1.0" dependencies = [ "bitcoin-dogecoin", "candid", + "ic-btc-canister", + "ic-btc-interface", "ic-btc-test-utils", "ic-cdk 0.20.0", + "pocket-ic", "serde", + "serde_bytes", ] [[package]] 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..68dc7095 100644 --- a/e2e-tests/scenario-1/Cargo.toml +++ b/e2e-tests/scenario-1/Cargo.toml @@ -9,3 +9,9 @@ candid = { workspace = true } ic-btc-test-utils = { workspace = true } 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/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/tests.rs b/e2e-tests/scenario-1/tests/tests.rs new file mode 100644 index 00000000..47d8c637 --- /dev/null +++ b/e2e-tests/scenario-1/tests/tests.rs @@ -0,0 +1,375 @@ +use candid::{CandidType, Deserialize, Principal}; +use ic_btc_canister::types::{HttpRequest, HttpResponse}; +use ic_btc_interface::{ + BlockchainInfo, CanisterArg, GetBalanceRequest, GetBlockHeadersRequest, + GetBlockHeadersResponse, GetUtxosRequest, GetUtxosResponse, InitConfig, Network, + NetworkInRequest, +}; +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}; + +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"); + + // 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); + + 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, + 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_until_main_chain_height(&self, target: u32, max_ticks: u32) { + for _ in 0..max_ticks { + self.pic.tick(); + if self.get_blockchain_info().height >= target { + 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 tick_until_stable_height(&self, target: u32, max_ticks: u32) { + for _ in 0..max_ticks { + self.pic.tick(); + if self.get_stable_height() >= target { + return; + } + } + panic!("timed out after {max_ticks} ticks waiting for stable height {target}"); + } + + fn get_stable_height(&self) -> u32 { + 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(), + ) + .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, + "metrics endpoint returned {}: {}", + 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"); + // 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. + let line = body + .lines() + .find(|line| line.starts_with("stable_height ") || line.starts_with("stable_height{")) + .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( + &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(), + 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. 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. 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. + 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 + ); + + // 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); +}