diff --git a/.github/workflows/basic_bitcoin.yml b/.github/workflows/basic_bitcoin.yml index 4733e1fa08..227a86f070 100644 --- a/.github/workflows/basic_bitcoin.yml +++ b/.github/workflows/basic_bitcoin.yml @@ -40,4 +40,4 @@ jobs: run: | icp network start -d icp deploy --cycles 30t - make test + bash test.sh diff --git a/motoko/basic_bitcoin/Makefile b/motoko/basic_bitcoin/Makefile deleted file mode 100644 index a1509970df..0000000000 --- a/motoko/basic_bitcoin/Makefile +++ /dev/null @@ -1,62 +0,0 @@ -IMAGE_NAME = icp-cli-network-launcher-bitcoin -# Find the running container built from our custom image -BITCOIN_CONTAINER = $(shell docker ps --filter "ancestor=$(IMAGE_NAME)" --format "{{.ID}}" | head -1) - -.PHONY: build-image test topup - -build-image: - # Because we're building off of :latest, use --pull to fetch the latest image. - # Remove `--pull` if the Dockerfile is updated to pin the base image version. - docker build --pull -t $(IMAGE_NAME) . - -topup: - icp canister top-up --amount 30t backend - -test: - @echo "=== Test 1: get_p2pkh_address returns a valid Bitcoin address ===" - @result=$$(icp canister call backend get_p2pkh_address '()') && \ - echo "$$result" && \ - echo "$$result" | grep -q '"' && \ - echo "PASS" || (echo "FAIL" && exit 1) - - @echo "=== Test 2: get_p2tr_key_only_address returns a valid Bitcoin address ===" - @result=$$(icp canister call backend get_p2tr_key_only_address '()') && \ - echo "$$result" && \ - echo "$$result" | grep -q '"' && \ - echo "PASS" || (echo "FAIL" && exit 1) - - @echo "=== Test 3: get_p2tr_address returns a valid Bitcoin address ===" - @result=$$(icp canister call backend get_p2tr_address '()') && \ - echo "$$result" && \ - echo "$$result" | grep -q '"' && \ - echo "PASS" || (echo "FAIL" && exit 1) - - @echo "=== Test 4: get_current_fee_percentiles returns a vec ===" - @result=$$(icp canister call backend get_current_fee_percentiles '()') && \ - echo "$$result" && \ - echo "PASS" || (echo "FAIL" && exit 1) - - @echo "=== Mining 101 blocks to fund test address ===" - @[ -n "$(BITCOIN_CONTAINER)" ] || (echo "ERROR: network launcher container not running — run 'icp network start -d' first" && exit 1) - @addr=$$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ - docker exec $(BITCOIN_CONTAINER) bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 101 "$$addr" > /dev/null && \ - echo "mined 101 blocks to $$addr" - - @echo "=== Waiting for IC to sync Bitcoin blocks ===" - @sleep 5 - - @echo "=== Test 5: get_balance returns non-zero after mining ===" - @addr=$$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ - result=$$(icp canister call backend get_balance "(\"$$addr\")") && \ - echo "$$result" && \ - echo "$$result" | grep -qE '[1-9]' && \ - echo "PASS" || (echo "FAIL" && exit 1) - - @echo "=== Test 6: get_utxos returns synced chain state after mining ===" - @addr=$$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ - result=$$(icp canister call backend get_utxos "(\"$$addr\")") && \ - echo "$$result" && \ - echo "$$result" | grep -q 'tip_height = 101' && \ - echo "PASS" || (echo "FAIL" && exit 1) diff --git a/motoko/basic_bitcoin/README.md b/motoko/basic_bitcoin/README.md index 32b9b3112f..7fb74f57f8 100644 --- a/motoko/basic_bitcoin/README.md +++ b/motoko/basic_bitcoin/README.md @@ -24,10 +24,10 @@ The example covers three Bitcoin address types, each backed by a different signi ### Prerequisites -- Node.js -- icp-cli: `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` -- ic-mops: `npm install -g ic-mops` -- Docker (required for local testing — bundles the IC network launcher + `bitcoind`) +- [Node.js](https://nodejs.org/) v18+ +- [icp-cli](https://cli.internetcomputer.org/): `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` +- [ic-mops](https://mops.one/): `npm install -g ic-mops` +- [Docker](https://docs.docker.com/get-docker/) (required for local testing — bundles the IC network launcher + `bitcoind`) ### Install @@ -41,7 +41,7 @@ cd examples/motoko/basic_bitcoin The local environment uses a self-contained Docker image (`icp-cli-network-launcher-bitcoin`) that runs `bitcoind` in regtest mode alongside the IC network. Build it once: ```bash -make build-image +./build-image.sh ``` Then deploy and run tests: @@ -53,27 +53,27 @@ make test icp network stop ``` -> If tests fail with an out-of-cycles error, run `make topup` and retry. +> If tests fail with an out-of-cycles error, top up the canister and retry: +> ```bash +> icp canister top-up --amount 30t backend +> ``` -### Staging (IC mainnet, Bitcoin testnet4) +### Deploy to the IC network -```bash -icp deploy -e staging -``` - -### Production (IC mainnet, Bitcoin mainnet) +The `ic` environment deploys to IC mainnet connected to Bitcoin testnet4, using `test_key_1`: ```bash -icp deploy -e production +icp deploy -e ic --cycles 30t ``` +> To deploy to Bitcoin mainnet, change the `init_args` for the `ic` environment in `icp.yaml` from `testnet` to `mainnet`. The canister automatically selects `key_1` (the production threshold signing key) when initialized with the `mainnet` variant. + ## Environments | Environment | IC network | Bitcoin network | Key | |-------------|-----------|----------------|-----| | `local` | local (PocketIC) | regtest | `test_key_1` | -| `staging` | IC mainnet | testnet4 | `test_key_1` | -| `production` | IC mainnet | mainnet | `key_1` | +| `ic` | IC mainnet | testnet4 | `test_key_1` | ## Available functions diff --git a/motoko/basic_bitcoin/backend/app.mo b/motoko/basic_bitcoin/backend/app.mo index 8adc3003bd..af7b14d138 100644 --- a/motoko/basic_bitcoin/backend/app.mo +++ b/motoko/basic_bitcoin/backend/app.mo @@ -12,7 +12,7 @@ actor class BasicBitcoin(network : Types.Network) { /// The Bitcoin network to connect to. /// Passed as an init arg and determined by the environment: - /// `regtest` (local), `testnet` (staging), or `mainnet` (production). + /// `regtest` (local), `testnet` (ic — default), or `mainnet` (ic — production). let NETWORK : Types.Network = network; /// The derivation path to use for ECDSA secp256k1 or Schnorr BIP340/BIP341 key @@ -20,8 +20,8 @@ actor class BasicBitcoin(network : Types.Network) { transient let DERIVATION_PATH : [[Nat8]] = []; // The ECDSA/Schnorr key name depends on which Bitcoin network this canister targets: - // - "key_1" — Bitcoin mainnet on ICP mainnet - // - "test_key_1" — Bitcoin testnet4 on ICP mainnet (staging) OR local regtest + // - "key_1" — Bitcoin mainnet on ICP mainnet (production) + // - "test_key_1" — Bitcoin testnet4 on ICP mainnet OR local regtest transient let KEY_NAME : Text = switch NETWORK { case (#mainnet) "key_1"; case (#testnet or #regtest) "test_key_1"; diff --git a/motoko/basic_bitcoin/build-image.sh b/motoko/basic_bitcoin/build-image.sh new file mode 100755 index 0000000000..31f95a1136 --- /dev/null +++ b/motoko/basic_bitcoin/build-image.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e +# Uses --pull to always fetch the latest base image. +# Remove --pull if the Dockerfile is updated to pin the base image version. +docker build --pull -t icp-cli-network-launcher-bitcoin . diff --git a/motoko/basic_bitcoin/icp.yaml b/motoko/basic_bitcoin/icp.yaml index cf1e2e6fd9..ddb94934dd 100644 --- a/motoko/basic_bitcoin/icp.yaml +++ b/motoko/basic_bitcoin/icp.yaml @@ -16,12 +16,10 @@ environments: init_args: backend: "(variant { regtest })" - - name: staging + - name: ic network: ic + # Deploys to Bitcoin testnet4 using test_key_1 by default. + # For Bitcoin mainnet, change to "(variant { mainnet })" — the canister + # automatically switches to key_1 when initialized with the mainnet variant. init_args: backend: "(variant { testnet })" - - - name: production - network: ic - init_args: - backend: "(variant { mainnet })" diff --git a/motoko/basic_bitcoin/test.sh b/motoko/basic_bitcoin/test.sh new file mode 100755 index 0000000000..ae8fec2c31 --- /dev/null +++ b/motoko/basic_bitcoin/test.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -e + +IMAGE_NAME=icp-cli-network-launcher-bitcoin +# Find the running container built from our custom image +BITCOIN_CONTAINER=$(docker ps --filter "ancestor=${IMAGE_NAME}" --format "{{.ID}}" | head -1) + +echo "=== Test 1: get_p2pkh_address returns a valid Bitcoin address ===" +result=$(icp canister call backend get_p2pkh_address '()') && \ + echo "$result" && \ + echo "$result" | grep -q '"' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 2: get_p2tr_key_only_address returns a valid Bitcoin address ===" +result=$(icp canister call backend get_p2tr_key_only_address '()') && \ + echo "$result" && \ + echo "$result" | grep -q '"' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 3: get_p2tr_address returns a valid Bitcoin address ===" +result=$(icp canister call backend get_p2tr_address '()') && \ + echo "$result" && \ + echo "$result" | grep -q '"' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 4: get_current_fee_percentiles returns a vec ===" +result=$(icp canister call backend get_current_fee_percentiles '()') && \ + echo "$result" && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Mining 101 blocks to fund test address ===" +[ -n "$BITCOIN_CONTAINER" ] || (echo "ERROR: network launcher container not running — run 'icp network start -d' first" && exit 1) +addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ + docker exec "$BITCOIN_CONTAINER" bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 101 "$addr" > /dev/null && \ + echo "mined 101 blocks to $addr" + +echo "=== Waiting for IC to sync Bitcoin blocks ===" +_sync_addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') +# Poll get_utxos until it succeeds AND reports a non-zero tip_height. +# Using get_utxos (not get_balance) because it reflects the definitive sync state: +# get_balance can return stale non-zero values from previous runs while the bitcoin +# integration canister is still syncing new blocks and rejecting all fresh calls. +# The || { sleep; continue; } pattern retries on both rejection errors and zero height. +_synced=false +for _i in $(seq 1 60); do + _result=$(icp canister call backend get_utxos "(\"$_sync_addr\")" 2>/dev/null) \ + || { sleep 1; continue; } + echo "$_result" | grep -qE 'tip_height = [1-9][0-9]*' \ + || { sleep 1; continue; } + echo "IC synced after ${_i}s" + _synced=true + break +done +[ "$_synced" = true ] || { echo "FAIL: IC did not sync within 60s"; exit 1; } + +echo "=== Test 5: get_balance returns non-zero after mining ===" +addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ + result=$(icp canister call backend get_balance "(\"$addr\")") && \ + echo "$result" && \ + echo "$result" | grep -qE '^\([1-9]' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 6: get_utxos returns synced chain state after mining ===" +addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ + result=$(icp canister call backend get_utxos "(\"$addr\")") && \ + echo "$result" && \ + echo "$result" | grep -qE 'tip_height = [1-9][0-9]*' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== All tests passed ==="