From 41b38de8c602158af4a572594c8dec6a90ff866b Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 6 Feb 2026 19:19:39 +0100 Subject: [PATCH 01/13] feat: tmp docker-compose template --- bitcoin/.gitignore | 2 + bitcoin/README.md | 185 +++++++ bitcoin/cargo-generate.toml | 14 + bitcoin/docker-compose.bitcoin.yml | 45 ++ bitcoin/icp.yaml | 20 + bitcoin/motoko-backend/.gitignore | 2 + bitcoin/motoko-backend/backend.did | 44 ++ bitcoin/motoko-backend/canister.yaml | 9 + bitcoin/motoko-backend/src/main.mo | 120 +++++ bitcoin/rename-backend-dir.rhai | 15 + bitcoin/rust-backend/.gitignore | 2 + bitcoin/rust-backend/Cargo.toml | 13 + bitcoin/rust-backend/backend.did | 47 ++ bitcoin/rust-backend/canister.yaml | 12 + bitcoin/rust-backend/src/lib.rs | 401 +++++++++++++++ cargo-generate.toml | 1 + rust/Cargo.lock | 733 +++++++++++++++++++++++++++ 17 files changed, 1665 insertions(+) create mode 100644 bitcoin/.gitignore create mode 100644 bitcoin/README.md create mode 100644 bitcoin/cargo-generate.toml create mode 100644 bitcoin/docker-compose.bitcoin.yml create mode 100644 bitcoin/icp.yaml create mode 100644 bitcoin/motoko-backend/.gitignore create mode 100644 bitcoin/motoko-backend/backend.did create mode 100644 bitcoin/motoko-backend/canister.yaml create mode 100644 bitcoin/motoko-backend/src/main.mo create mode 100644 bitcoin/rename-backend-dir.rhai create mode 100644 bitcoin/rust-backend/.gitignore create mode 100644 bitcoin/rust-backend/Cargo.toml create mode 100644 bitcoin/rust-backend/backend.did create mode 100644 bitcoin/rust-backend/canister.yaml create mode 100644 bitcoin/rust-backend/src/lib.rs create mode 100644 rust/Cargo.lock diff --git a/bitcoin/.gitignore b/bitcoin/.gitignore new file mode 100644 index 0000000..d39e572 --- /dev/null +++ b/bitcoin/.gitignore @@ -0,0 +1,2 @@ +.icp/cache/ +target/ diff --git a/bitcoin/README.md b/bitcoin/README.md new file mode 100644 index 0000000..a1375a6 --- /dev/null +++ b/bitcoin/README.md @@ -0,0 +1,185 @@ +# Bitcoin Integration Template + +This template demonstrates the full Bitcoin integration on the Internet Computer: + +- **Derive a Bitcoin address** controlled by the canister via threshold ECDSA (tECDSA) +- **Receive Bitcoin** by mining to the canister's address (on regtest) +- **Send Bitcoin** to any address +- **Query balances and UTXOs** + +## Prerequisites + +- [icp-cli](https://github.com/dfinity/icp-cli) installed +- [Docker](https://docs.docker.com/get-docker/) installed and running + +## Step 1: Create and Deploy + +Create a new project from this template, start the local network, and deploy: + +```bash +icp new my-bitcoin-project --template bitcoin +cd my-bitcoin-project +icp network start +icp build && icp deploy +``` + +This starts a local Bitcoin regtest node and an IC replica with Bitcoin integration via Docker Compose. + +## Step 2: Get the Canister's Bitcoin Address + +The canister derives a P2WPKH Bitcoin address from its threshold ECDSA key: + +```bash +icp canister call backend get_canister_btc_address '()' +``` + +This returns a `bcrt1q...` address (regtest SegWit format). Save it for the next steps: + +```bash +CANISTER_BTC_ADDR=$(icp canister call backend get_canister_btc_address '()' | grep -o '"[^"]*"' | tr -d '"') +echo "$CANISTER_BTC_ADDR" +``` + +## Step 3: Fund the Canister (Mine Blocks) + +On regtest, you fund addresses by mining blocks to them. Bitcoin requires **100 confirmations** before coinbase rewards are spendable, so mine 101 blocks: + +```bash +docker compose -p icp-local exec bitcoind \ + bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 101 "$CANISTER_BTC_ADDR" +``` + +> **Note:** `icp network start` runs the compose project under the name `icp-local`, so use `-p icp-local` instead of `-f docker-compose.bitcoin.yml` when interacting with running services. + +This gives the canister 50 BTC (the block reward from the first block, now mature after 100 confirmations). + +## Step 4: Check the Balance + +Query the canister's balance (in satoshis): + +```bash +icp canister call backend get_balance "(\"$CANISTER_BTC_ADDR\")" +``` + +You should see `5_000_000_000` (50 BTC = 5 billion satoshis). + +You can also view the UTXOs: + +```bash +icp canister call backend get_utxos "(\"$CANISTER_BTC_ADDR\")" +``` + +## Step 5: Transfer BTC + +Create a wallet in bitcoind, generate a destination address, and transfer BTC to it: + +```bash +# Create a wallet in bitcoind (needed once, Bitcoin Core doesn't auto-create one) +docker compose -p icp-local exec bitcoind \ + bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + createwallet "default" + +# Create a destination address +DEST_ADDR=$(docker compose -p icp-local exec bitcoind \ + bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + getnewaddress) + +# Transfer 1 BTC (100,000,000 satoshis) +icp canister call backend transfer_btc \ + "(record { destination = \"$DEST_ADDR\"; amount_in_satoshi = 100_000_000 : nat64 })" +``` + +This returns the transaction ID. + +## Step 6: Confirm and Verify + +Mine a block to confirm the transaction: + +```bash +docker compose -p icp-local exec bitcoind \ + bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$CANISTER_BTC_ADDR" +``` + +Check the updated balances: + +```bash +# Canister balance (should be reduced by ~1 BTC + fee) +icp canister call backend get_balance "(\"$CANISTER_BTC_ADDR\")" + +# Destination balance +icp canister call backend get_balance "(\"$DEST_ADDR\")" +``` + +## Canister API + +| Function | Type | Description | +|----------|------|-------------| +| `get_canister_btc_address` | update | Returns a Bitcoin address controlled by the canister | +| `get_balance(address)` | update | Returns the balance in satoshis | +| `get_utxos(address)` | update | Returns the UTXOs for an address | +| `get_fee_percentiles` | update | Returns current fee percentiles (millisatoshi/vbyte) | +| `transfer_btc({destination, amount_in_satoshi})` | update | Sends BTC and returns the transaction ID | +| `get_bitcoin_info` | query | Returns the configured Bitcoin network | + +## Configuration + +### Network Configuration + +The `icp.yaml` defines the local network using Docker Compose: + +```yaml +networks: + - name: local + mode: managed + compose: + file: docker-compose.bitcoin.yml + gateway-service: icp-network +``` + +This tells icp-cli to use Docker Compose to manage the local network, starting both the Bitcoin regtest node and the IC replica together. + +### Bitcoin Network Selection + +The backend canister reads the `BITCOIN_NETWORK` environment variable to determine which Bitcoin network to use: + +- `regtest` (default) — Local regtest network via Docker Compose +- `testnet` — Bitcoin testnet +- `mainnet` — Bitcoin mainnet + +The environment variable is configured in the environments section of `icp.yaml`: + +```yaml +environments: + - name: local + network: local + settings: + backend: + environment_variables: + BITCOIN_NETWORK: "regtest" +``` + +## Project Structure + +``` +. +├── icp.yaml # Project configuration +├── docker-compose.bitcoin.yml # Local Bitcoin + IC network setup +├── backend/ +│ ├── canister.yaml # Canister build configuration +│ ├── src/ +│ │ └── lib.rs # Backend canister code +│ └── backend.did # Candid interface +└── README.md +``` + +## Learn More + +- [Internet Computer Bitcoin Integration](https://internetcomputer.org/docs/building-apps/bitcoin/overview) +- [icp-cli Documentation](https://github.com/dfinity/icp-cli) +- [Bitcoin Regtest Mode](https://developer.bitcoin.org/examples/testing.html) diff --git a/bitcoin/cargo-generate.toml b/bitcoin/cargo-generate.toml new file mode 100644 index 0000000..0f533e6 --- /dev/null +++ b/bitcoin/cargo-generate.toml @@ -0,0 +1,14 @@ +[template] +description = "ICP project with local Bitcoin integration using Docker Compose" + +[placeholders] +backend_type = { type = "string", prompt = "Choose your backend language:", choices = ["rust", "motoko"], default = "rust" } + +[conditional.'backend_type == "rust"'] +ignore = [ "motoko-backend" ] + +[conditional.'backend_type == "motoko"'] +ignore = [ "rust-backend" ] + +[hooks] +pre = ["rename-backend-dir.rhai"] diff --git a/bitcoin/docker-compose.bitcoin.yml b/bitcoin/docker-compose.bitcoin.yml new file mode 100644 index 0000000..8c72244 --- /dev/null +++ b/bitcoin/docker-compose.bitcoin.yml @@ -0,0 +1,45 @@ +# Docker Compose setup for local ICP development with Bitcoin integration +# +# This compose file runs: +# - bitcoind: Bitcoin Core in regtest mode for local testing +# - icp-network: IC network launcher with Bitcoin subnet support +# +# The network launcher connects to bitcoind and provides a local IC replica +# with native Bitcoin integration capabilities. + +services: + # Bitcoin Core node running in regtest mode + bitcoind: + image: lncm/bitcoind:v27.2 + command: + - -regtest + - -server + - -rpcbind=0.0.0.0 + - -rpcallowip=0.0.0.0/0 + - -rpcuser=ic-btc-integration + - -rpcpassword=ic-btc-integration + - -fallbackfee=0.00001 + - -txindex=1 + ports: + - "18443:18443" # RPC port + - "18444:18444" # P2P port + healthcheck: + test: ["CMD", "bitcoin-cli", "-regtest", "-rpcuser=ic-btc-integration", "-rpcpassword=ic-btc-integration", "getblockchaininfo"] + interval: 5s + timeout: 5s + retries: 20 + + # IC Network launcher with Bitcoin subnet + icp-network: + image: ghcr.io/dfinity/icp-cli-network-launcher:latest + depends_on: + bitcoind: + condition: service_healthy + environment: + - ICP_CLI_NETWORK_LAUNCHER_INTERFACE_VERSION=1.0.0 + command: + - --bitcoind-addr=bitcoind:18444 + ports: + - "8000:4943" # IC gateway port (use 0 for a random host port) + volumes: + - "${ICP_STATUS_DIR:-/tmp/icp-status}:/app/status" diff --git a/bitcoin/icp.yaml b/bitcoin/icp.yaml new file mode 100644 index 0000000..2762bf0 --- /dev/null +++ b/bitcoin/icp.yaml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/icp-yaml-schema.json + +canisters: + - backend + +networks: + # Local network with Bitcoin integration via Docker Compose + - name: local + mode: managed + compose: + file: docker-compose.bitcoin.yml + gateway-service: icp-network + +environments: + - name: local + network: local + settings: + backend: + environment_variables: + BITCOIN_NETWORK: "regtest" diff --git a/bitcoin/motoko-backend/.gitignore b/bitcoin/motoko-backend/.gitignore new file mode 100644 index 0000000..4b5e51a --- /dev/null +++ b/bitcoin/motoko-backend/.gitignore @@ -0,0 +1,2 @@ +.vessel +.mops diff --git a/bitcoin/motoko-backend/backend.did b/bitcoin/motoko-backend/backend.did new file mode 100644 index 0000000..f9ee1c9 --- /dev/null +++ b/bitcoin/motoko-backend/backend.did @@ -0,0 +1,44 @@ +type satoshi = nat64; + +type millisatoshi_per_vbyte = nat64; + +type bitcoin_address = text; + +type outpoint = record { + txid : blob; + vout : nat32; +}; + +type utxo = record { + outpoint : outpoint; + value : satoshi; + height : nat32; +}; + +type get_utxos_response = record { + utxos : vec utxo; + tip_block_hash : blob; + tip_height : nat32; + next_page : opt blob; +}; + +type bitcoin_info = record { + network : text; +}; + +service : { + // Query the balance of a Bitcoin address + "get_balance" : (bitcoin_address) -> (satoshi); + + // Query the UTXOs for a Bitcoin address + "get_utxos" : (bitcoin_address) -> (get_utxos_response); + + // Get current fee percentiles + "get_fee_percentiles" : () -> (vec millisatoshi_per_vbyte); + + // Get Bitcoin network configuration info + "get_bitcoin_info" : () -> (bitcoin_info) query; + + // Simple greeting to verify the canister is working + "greet" : (text) -> (text) query; +} diff --git a/bitcoin/motoko-backend/canister.yaml b/bitcoin/motoko-backend/canister.yaml new file mode 100644 index 0000000..5264d2b --- /dev/null +++ b/bitcoin/motoko-backend/canister.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/canister-yaml-schema.json + +name: backend +recipe: + type: "@dfinity/motoko" + configuration: + main: src/main.mo + args: --incremental-gc + candid: backend.did diff --git a/bitcoin/motoko-backend/src/main.mo b/bitcoin/motoko-backend/src/main.mo new file mode 100644 index 0000000..6696084 --- /dev/null +++ b/bitcoin/motoko-backend/src/main.mo @@ -0,0 +1,120 @@ +/// Bitcoin Integration Example +/// +/// This canister demonstrates basic Bitcoin integration on the Internet Computer. +/// It provides functions to: +/// - Check Bitcoin balance for any address +/// - Get UTXOs (Unspent Transaction Outputs) for any address +/// - Get current fee percentiles + +import Prim "mo:⛔"; +import Text "mo:core/Text"; + +persistent actor Backend { + // Types from the management canister Bitcoin API + public type Satoshi = Nat64; + public type MillisatoshiPerVByte = Nat64; + public type BitcoinAddress = Text; + + public type Network = { + #mainnet; + #testnet; + #regtest; + }; + + public type Outpoint = { + txid : Blob; + vout : Nat32; + }; + + public type Utxo = { + outpoint : Outpoint; + value : Satoshi; + height : Nat32; + }; + + public type GetUtxosResponse = { + utxos : [Utxo]; + tip_block_hash : Blob; + tip_height : Nat32; + next_page : ?Blob; + }; + + public type BitcoinInfo = { + network : Text; + }; + + // Management canister interface for Bitcoin + let management_canister : actor { + bitcoin_get_balance : shared { + address : BitcoinAddress; + network : Network; + min_confirmations : ?Nat32; + } -> async Satoshi; + + bitcoin_get_utxos : shared { + address : BitcoinAddress; + network : Network; + filter : ?{ #min_confirmations : Nat32; #page : Blob }; + } -> async GetUtxosResponse; + + bitcoin_get_current_fee_percentiles : shared { + network : Network; + } -> async [MillisatoshiPerVByte]; + } = actor ("aaaaa-aa"); + + /// Get the Bitcoin network from the BITCOIN_NETWORK environment variable. + private func getNetwork() : Network { + switch (Prim.envVar("BITCOIN_NETWORK")) { + case (?value) { + let networkStr = Text.toLower(value); + switch (networkStr) { + case ("mainnet") #mainnet; + case ("testnet") #testnet; + case _ #regtest; + }; + }; + case null #regtest; + }; + }; + + /// Get the balance of a Bitcoin address in satoshis. + public func get_balance(address : BitcoinAddress) : async Satoshi { + await management_canister.bitcoin_get_balance({ + address; + network = getNetwork(); + min_confirmations = null; + }); + }; + + /// Get the UTXOs for a Bitcoin address. + public func get_utxos(address : BitcoinAddress) : async GetUtxosResponse { + await management_canister.bitcoin_get_utxos({ + address; + network = getNetwork(); + filter = null; + }); + }; + + /// Get the current Bitcoin fee percentiles. + public func get_fee_percentiles() : async [MillisatoshiPerVByte] { + await management_canister.bitcoin_get_current_fee_percentiles({ + network = getNetwork(); + }); + }; + + /// Get information about the Bitcoin canister configuration. + public query func get_bitcoin_info() : async BitcoinInfo { + let network = getNetwork(); + let networkText = switch (network) { + case (#mainnet) "Mainnet"; + case (#testnet) "Testnet"; + case (#regtest) "Regtest"; + }; + { network = networkText }; + }; + + /// Simple greeting function to verify the canister is working. + public query func greet(name : Text) : async Text { + "Hello, " # name # "! This canister supports Bitcoin integration."; + }; +}; diff --git a/bitcoin/rename-backend-dir.rhai b/bitcoin/rename-backend-dir.rhai new file mode 100644 index 0000000..18e1a8e --- /dev/null +++ b/bitcoin/rename-backend-dir.rhai @@ -0,0 +1,15 @@ +// This script renames the `-backend` directory to `backend` +// By the time this script is called the conditionals would have filtered +// all the unselected backend folders + +let backend_type = variable::get("backend_type"); +debug(`backend_type: ${backend_type}`); + +switch backend_type { + "rust" => { + file::rename("rust-backend", "backend"); + } + "motoko" => { + file::rename("motoko-backend", "backend"); + } +} diff --git a/bitcoin/rust-backend/.gitignore b/bitcoin/rust-backend/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/bitcoin/rust-backend/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/bitcoin/rust-backend/Cargo.toml b/bitcoin/rust-backend/Cargo.toml new file mode 100644 index 0000000..9237f40 --- /dev/null +++ b/bitcoin/rust-backend/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "backend" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +bitcoin = "0.32" +candid = "0.10" +ic-cdk = "0.19" +serde = { version = "1.0", features = ["derive"] } diff --git a/bitcoin/rust-backend/backend.did b/bitcoin/rust-backend/backend.did new file mode 100644 index 0000000..17d7acb --- /dev/null +++ b/bitcoin/rust-backend/backend.did @@ -0,0 +1,47 @@ +type satoshi = nat64; + +type millisatoshi_per_vbyte = nat64; + +type bitcoin_address = text; + +type outpoint = record { + txid : blob; + vout : nat32; +}; + +type utxo = record { + outpoint : outpoint; + value : satoshi; + height : nat32; +}; + +type get_utxos_response = record { + utxos : vec utxo; + tip_block_hash : blob; + tip_height : nat32; + next_page : opt blob; +}; + +type bitcoin_info = record { + network : text; +}; + +service : { + // Derive a Bitcoin address controlled by this canister via threshold ECDSA + "get_canister_btc_address" : () -> (text); + + // Query the balance of a Bitcoin address + "get_balance" : (bitcoin_address) -> (satoshi); + + // Query the UTXOs for a Bitcoin address + "get_utxos" : (bitcoin_address) -> (get_utxos_response); + + // Get current fee percentiles + "get_fee_percentiles" : () -> (vec millisatoshi_per_vbyte); + + // Transfer Bitcoin to a destination address, returns the transaction ID + "transfer_btc" : (record { destination : text; amount_in_satoshi : nat64 }) -> (text); + + // Get Bitcoin network configuration info + "get_bitcoin_info" : () -> (bitcoin_info) query; +} diff --git a/bitcoin/rust-backend/canister.yaml b/bitcoin/rust-backend/canister.yaml new file mode 100644 index 0000000..8faf89e --- /dev/null +++ b/bitcoin/rust-backend/canister.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/canister-yaml-schema.json + +name: backend +recipe: + type: "@dfinity/rust" + configuration: + package: backend + shrink: true + candid: backend.did + metadata: + - name: "crate:version" + value: 1.0.0 diff --git a/bitcoin/rust-backend/src/lib.rs b/bitcoin/rust-backend/src/lib.rs new file mode 100644 index 0000000..32c6de3 --- /dev/null +++ b/bitcoin/rust-backend/src/lib.rs @@ -0,0 +1,401 @@ +//! Bitcoin Integration Example +//! +//! This canister demonstrates Bitcoin integration on the Internet Computer: +//! - Derive a Bitcoin address controlled by the canister via threshold ECDSA +//! - Receive Bitcoin by mining to the canister's address (on regtest) +//! - Send Bitcoin to any address +//! - Query balances and UTXOs +//! +//! The Bitcoin network is configured via the BITCOIN_NETWORK environment variable. + +use bitcoin::{ + absolute::LockTime, + consensus::serialize, + hashes::Hash, + sighash::{EcdsaSighashType, SighashCache}, + transaction::Version, + Address, Amount, CompressedPublicKey, Network as BtcNetwork, OutPoint, ScriptBuf, Sequence, + Transaction, TxIn, TxOut, Txid, Witness, +}; +use candid::CandidType; +use ic_cdk::bitcoin_canister::{ + bitcoin_get_balance, bitcoin_get_current_fee_percentiles, bitcoin_get_utxos, + bitcoin_send_transaction, GetBalanceRequest, GetCurrentFeePercentilesRequest, GetUtxosRequest, + GetUtxosResponse, MillisatoshiPerByte, Network, Satoshi, SendTransactionRequest, Utxo, +}; +use ic_cdk::management_canister::{EcdsaCurve, EcdsaKeyId, EcdsaPublicKeyArgs, SignWithEcdsaArgs}; +use serde::Deserialize; + +const DUST_THRESHOLD: u64 = 1_000; + +// --------------------------------------------------------------------------- +// Bitcoin network helpers +// --------------------------------------------------------------------------- + +/// Get the IC Bitcoin network from the BITCOIN_NETWORK environment variable. +fn get_network() -> Network { + let network_str = if ic_cdk::api::env_var_name_exists("BITCOIN_NETWORK") { + ic_cdk::api::env_var_value("BITCOIN_NETWORK").to_lowercase() + } else { + "regtest".to_string() + }; + + match network_str.as_str() { + "mainnet" => Network::Mainnet, + "testnet" => Network::Testnet, + _ => Network::Regtest, + } +} + +/// Map the IC network type to the rust-bitcoin network type. +fn to_btc_network(network: Network) -> BtcNetwork { + match network { + Network::Mainnet => BtcNetwork::Bitcoin, + Network::Testnet => BtcNetwork::Testnet, + Network::Regtest => BtcNetwork::Regtest, + } +} + +/// The ECDSA key name used by the IC subnet. +fn ecdsa_key_name() -> String { + match get_network() { + Network::Regtest | Network::Testnet => "test_key_1".to_string(), + Network::Mainnet => "key_1".to_string(), + } +} + +fn ecdsa_key_id() -> EcdsaKeyId { + EcdsaKeyId { + curve: EcdsaCurve::Secp256k1, + name: ecdsa_key_name(), + } +} + +/// A fixed derivation path for the canister's Bitcoin key. +fn derivation_path() -> Vec> { + vec![b"btc".to_vec()] +} + +// --------------------------------------------------------------------------- +// Address derivation +// --------------------------------------------------------------------------- + +/// Fetch the canister's compressed ECDSA public key from the IC management canister. +async fn get_ecdsa_public_key() -> Vec { + ic_cdk::management_canister::ecdsa_public_key(&EcdsaPublicKeyArgs { + canister_id: None, + derivation_path: derivation_path(), + key_id: ecdsa_key_id(), + }) + .await + .expect("Failed to get ECDSA public key") + .public_key +} + +/// Derive the canister's P2WPKH Bitcoin address from its ECDSA public key. +async fn get_p2wpkh_address() -> Address { + let public_key_bytes = get_ecdsa_public_key().await; + let compressed_key = CompressedPublicKey::from_slice(&public_key_bytes) + .expect("Invalid 33-byte compressed public key"); + Address::p2wpkh(&compressed_key, to_btc_network(get_network())) +} + +// --------------------------------------------------------------------------- +// Transaction building +// --------------------------------------------------------------------------- + +/// Select UTXOs greedily to cover `amount + fee`. +fn select_utxos(utxos: &[Utxo], amount: u64, fee: u64) -> Vec { + let target = amount + fee; + let mut selected = Vec::new(); + let mut total = 0u64; + for utxo in utxos.iter().rev() { + selected.push(utxo.clone()); + total += utxo.value; + if total >= target { + return selected; + } + } + panic!( + "Insufficient balance: have {} satoshi, need {} (amount {} + fee {})", + total, target, amount, fee + ); +} + +/// Build a transaction spending `utxos_to_spend` with one output to `dst_address` +/// and an optional change output back to `own_address`. +fn build_transaction( + utxos_to_spend: &[Utxo], + own_address: &Address, + dst_address: &Address, + amount: u64, + fee: u64, +) -> Transaction { + let inputs: Vec = utxos_to_spend + .iter() + .map(|utxo| TxIn { + previous_output: OutPoint { + txid: Txid::from_raw_hash(Hash::from_slice(&utxo.outpoint.txid).unwrap()), + vout: utxo.outpoint.vout, + }, + sequence: Sequence::MAX, + script_sig: ScriptBuf::new(), + witness: Witness::new(), + }) + .collect(); + + let mut outputs = vec![TxOut { + value: Amount::from_sat(amount), + script_pubkey: dst_address.script_pubkey(), + }]; + + let total_in: u64 = utxos_to_spend.iter().map(|u| u.value).sum(); + let change = total_in - amount - fee; + if change >= DUST_THRESHOLD { + outputs.push(TxOut { + value: Amount::from_sat(change), + script_pubkey: own_address.script_pubkey(), + }); + } + + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs, + output: outputs, + } +} + +/// Estimate the transaction fee using iterative sizing with mock signatures. +fn estimate_fee( + utxos: &[Utxo], + own_address: &Address, + dst_address: &Address, + amount: u64, + fee_per_vbyte: u64, +) -> (Vec, Transaction, u64) { + let mut fee = 0u64; + loop { + let selected = select_utxos(utxos, amount, fee); + let tx = build_transaction(&selected, own_address, dst_address, amount, fee); + + // Create a mock-signed copy to measure the virtual size. + let signed = mock_sign_transaction(tx.clone()); + let vsize = signed.vsize() as u64; + let new_fee = (vsize * fee_per_vbyte) / 1000; + + if new_fee == fee { + return (selected, tx, fee); + } + fee = new_fee; + } +} + +/// Fill in witness data with dummy signatures for size estimation. +fn mock_sign_transaction(mut tx: Transaction) -> Transaction { + let mock_sig = [1u8; 64]; + let mock_pubkey = [2u8; 33]; + + for input in tx.input.iter_mut() { + let mut witness = Witness::new(); + // A DER-encoded ECDSA signature is at most 73 bytes + 1 sighash byte. + // Using a compact 64-byte representation here slightly underestimates, + // but the iterative loop will converge to the correct fee regardless. + witness.push(mock_sig); + witness.push(mock_pubkey); + input.witness = witness; + } + + tx +} + +/// Sign each transaction input with the canister's threshold ECDSA key (P2WPKH). +async fn sign_transaction( + mut tx: Transaction, + utxos_to_spend: &[Utxo], + own_address: &Address, + public_key_bytes: &[u8], +) -> Transaction { + let compressed_key = + CompressedPublicKey::from_slice(public_key_bytes).expect("Invalid compressed public key"); + + // Build the prevouts list (needed for SegWit sighash computation). + let prevouts: Vec = utxos_to_spend + .iter() + .map(|utxo| TxOut { + value: Amount::from_sat(utxo.value), + script_pubkey: own_address.script_pubkey(), + }) + .collect(); + + for index in 0..tx.input.len() { + let sighash = { + let mut cache = SighashCache::new(&tx); + cache + .p2wpkh_signature_hash( + index, + &prevouts[index].script_pubkey, + prevouts[index].value, + EcdsaSighashType::All, + ) + .expect("Failed to compute sighash") + }; + + let raw_signature = ic_cdk::management_canister::sign_with_ecdsa(&SignWithEcdsaArgs { + message_hash: sighash.as_byte_array().to_vec(), + derivation_path: derivation_path(), + key_id: ecdsa_key_id(), + }) + .await + .expect("Failed to sign with ECDSA") + .signature; + + let signature = bitcoin::secp256k1::ecdsa::Signature::from_compact(&raw_signature) + .expect("Invalid ECDSA signature"); + let bitcoin_sig = bitcoin::ecdsa::Signature { + signature, + sighash_type: EcdsaSighashType::All, + }; + + let mut witness = Witness::new(); + witness.push(bitcoin_sig.to_vec()); + witness.push(compressed_key.to_bytes()); + tx.input[index].witness = witness; + } + + tx +} + +// --------------------------------------------------------------------------- +// Public canister API +// --------------------------------------------------------------------------- + +/// Returns a Bitcoin address controlled by this canister via threshold ECDSA. +#[ic_cdk::update] +async fn get_canister_btc_address() -> String { + get_p2wpkh_address().await.to_string() +} + +/// Get the balance of a Bitcoin address in satoshis. +#[ic_cdk::update] +async fn get_balance(address: String) -> Satoshi { + bitcoin_get_balance(&GetBalanceRequest { + address, + network: get_network(), + min_confirmations: None, + }) + .await + .expect("Failed to get balance") +} + +/// Get the UTXOs for a Bitcoin address. +#[ic_cdk::update] +async fn get_utxos(address: String) -> GetUtxosResponse { + bitcoin_get_utxos(&GetUtxosRequest { + address, + network: get_network(), + filter: None, + }) + .await + .expect("Failed to get UTXOs") +} + +/// Get current Bitcoin fee percentiles (millisatoshi per vbyte). +#[ic_cdk::update] +async fn get_fee_percentiles() -> Vec { + bitcoin_get_current_fee_percentiles(&GetCurrentFeePercentilesRequest { + network: get_network(), + }) + .await + .expect("Failed to get fee percentiles") +} + +/// Transfer Bitcoin to a destination address. +/// +/// Returns the transaction ID of the submitted transaction. +#[ic_cdk::update] +async fn transfer_btc(request: TransferRequest) -> String { + let network = get_network(); + let btc_network = to_btc_network(network); + + // Parse and validate destination address. + let dst_address: Address = request + .destination + .parse::>() + .expect("Invalid destination address") + .require_network(btc_network) + .expect("Destination address does not match the configured Bitcoin network"); + + // Get the canister's own address and public key. + let public_key_bytes = get_ecdsa_public_key().await; + let compressed_key = + CompressedPublicKey::from_slice(&public_key_bytes).expect("Invalid compressed public key"); + let own_address = Address::p2wpkh(&compressed_key, btc_network); + + // Fetch UTXOs for the canister's address. + let utxos_response = bitcoin_get_utxos(&GetUtxosRequest { + address: own_address.to_string(), + network, + filter: None, + }) + .await + .expect("Failed to get UTXOs"); + + // Determine fee rate (median of fee percentiles, fallback for regtest). + let fee_percentiles = + bitcoin_get_current_fee_percentiles(&GetCurrentFeePercentilesRequest { network }) + .await + .expect("Failed to get fee percentiles"); + let fee_per_vbyte = if fee_percentiles.is_empty() { + 2000 // fallback: 2 sat/vbyte in millisatoshis + } else { + fee_percentiles[fee_percentiles.len() / 2] + }; + + // Build and sign the transaction. + let (selected_utxos, unsigned_tx, _fee) = estimate_fee( + &utxos_response.utxos, + &own_address, + &dst_address, + request.amount_in_satoshi, + fee_per_vbyte, + ); + + let signed_tx = + sign_transaction(unsigned_tx, &selected_utxos, &own_address, &public_key_bytes).await; + + let txid = signed_tx.compute_txid().to_string(); + + // Broadcast the transaction. + bitcoin_send_transaction(&SendTransactionRequest { + network, + transaction: serialize(&signed_tx), + }) + .await + .expect("Failed to send transaction"); + + txid +} + +/// Get information about the Bitcoin canister configuration. +#[ic_cdk::query] +fn get_bitcoin_info() -> BitcoinInfo { + BitcoinInfo { + network: format!("{:?}", get_network()), + } +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(CandidType, Deserialize)] +struct TransferRequest { + destination: String, + amount_in_satoshi: u64, +} + +#[derive(CandidType, Deserialize)] +struct BitcoinInfo { + network: String, +} diff --git a/cargo-generate.toml b/cargo-generate.toml index dfa642c..1dbcdc4 100644 --- a/cargo-generate.toml +++ b/cargo-generate.toml @@ -3,4 +3,5 @@ sub_templates = [ "motoko", "rust", "hello-world", + "bitcoin", ] diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..631fbba --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,733 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backend" +version = "0.1.0" +dependencies = [ + "candid", + "ic-cdk", +] + +[[package]] +name = "binread" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16598dfc8e6578e9b597d9910ba2e73618385dc9f4b1d43dd92c349d6be6418f" +dependencies = [ + "binread_derive", + "lazy_static", + "rustversion", +] + +[[package]] +name = "binread_derive" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9672209df1714ee804b1f4d4f68c8eb2a90b1f7a07acf472f88ce198ef1fed" +dependencies = [ + "either", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "candid" +version = "0.10.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49a6e53730e2d41f6fc3ad9ef4d9bc7ad738ddc6aed4ceb30a35a2cd63e9bcb" +dependencies = [ + "anyhow", + "binread", + "byteorder", + "candid_derive", + "hex", + "ic_principal", + "leb128", + "num-bigint", + "num-traits", + "paste", + "pretty", + "serde", + "serde_bytes", + "stacker", + "thiserror 1.0.69", +] + +[[package]] +name = "candid_derive" +version = "0.10.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab75e3283c7912bb2986dd7033a87e4e5f3f472158816308a04d40f5d697099c" +dependencies = [ + "lazy_static", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ic-cdk" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818d6d5416a8f0212e1b132703b0da51e36c55f2b96677e96f2bbe7702e1bd85" +dependencies = [ + "candid", + "ic-cdk-executor", + "ic-cdk-macros", + "ic-error-types", + "ic-management-canister-types", + "ic0", + "pin-project-lite", + "serde", + "serde_bytes", + "slotmap", + "thiserror 2.0.18", +] + +[[package]] +name = "ic-cdk-executor" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33716b730ded33690b8a704bff3533fda87d229e58046823647d28816e9bcee7" +dependencies = [ + "ic0", + "slotmap", + "smallvec", +] + +[[package]] +name = "ic-cdk-macros" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dad91a214945cb3605bc9ef6901b87e2ac41e3624284c2cabba49d43aa4f43" +dependencies = [ + "candid", + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ic-error-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbeeb3d91aa179d6496d7293becdacedfc413c825cac79fd54ea1906f003ee55" +dependencies = [ + "serde", + "strum", + "strum_macros", +] + +[[package]] +name = "ic-management-canister-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3149217e24186df3f13dc45eee14cdb3e5cad07d0b2b67bd53555c1c55462957" +dependencies = [ + "candid", + "serde", + "serde_bytes", +] + +[[package]] +name = "ic0" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1499d08fd5be8f790d477e1865d63bab6a8d748300e141270c4296e6d5fdd6bc" + +[[package]] +name = "ic_principal" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2b6c5941dfd659e77b262342fa58ad49489367ad026255cda8c43682d0c534" +dependencies = [ + "crc32fast", + "data-encoding", + "serde", + "sha2", + "thiserror 1.0.69", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec", + "typed-arena", + "unicode-width", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" From 3d403568bf6830d733c5ecc39be549e9723edf4e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 9 Feb 2026 20:57:35 +0100 Subject: [PATCH 02/13] feat: simplify bitcoin template to minimal get_balance example --- bitcoin/README.md | 186 ++++--------- bitcoin/cargo-generate.toml | 3 +- bitcoin/docker-compose.bitcoin.yml | 45 ---- bitcoin/icp.yaml | 29 +- bitcoin/motoko-backend/backend.did | 37 +-- bitcoin/motoko-backend/canister.yaml | 3 +- bitcoin/motoko-backend/mops.toml | 5 + bitcoin/motoko-backend/src/main.mo | 115 +++----- bitcoin/rust-backend/Cargo.toml | 2 - bitcoin/rust-backend/backend.did | 40 +-- bitcoin/rust-backend/canister.yaml | 2 +- bitcoin/rust-backend/src/lib.rs | 380 ++------------------------- 12 files changed, 154 insertions(+), 693 deletions(-) delete mode 100644 bitcoin/docker-compose.bitcoin.yml create mode 100644 bitcoin/motoko-backend/mops.toml diff --git a/bitcoin/README.md b/bitcoin/README.md index a1375a6..f746756 100644 --- a/bitcoin/README.md +++ b/bitcoin/README.md @@ -1,185 +1,115 @@ # Bitcoin Integration Template -This template demonstrates the full Bitcoin integration on the Internet Computer: +Demonstrates reading Bitcoin balance from a canister on the Internet Computer using the [Bitcoin canister API](https://github.com/dfinity/bitcoin-canister/blob/master/INTERFACE_SPECIFICATION.md). -- **Derive a Bitcoin address** controlled by the canister via threshold ECDSA (tECDSA) -- **Receive Bitcoin** by mining to the canister's address (on regtest) -- **Send Bitcoin** to any address -- **Query balances and UTXOs** +## Bitcoin Canister IDs -## Prerequisites - -- [icp-cli](https://github.com/dfinity/icp-cli) installed -- [Docker](https://docs.docker.com/get-docker/) installed and running - -## Step 1: Create and Deploy - -Create a new project from this template, start the local network, and deploy: - -```bash -icp new my-bitcoin-project --template bitcoin -cd my-bitcoin-project -icp network start -icp build && icp deploy -``` - -This starts a local Bitcoin regtest node and an IC replica with Bitcoin integration via Docker Compose. +| IC Network | Bitcoin Network | Bitcoin Canister ID | +|------------|----------------|---------------------| +| Local (PocketIC) | regtest | `g4xu7-jiaaa-aaaan-aaaaq-cai` | +| IC mainnet | testnet | `g4xu7-jiaaa-aaaan-aaaaq-cai` | +| IC mainnet | mainnet | `ghsi2-tqaaa-aaaan-aaaca-cai` | -## Step 2: Get the Canister's Bitcoin Address +The `BITCOIN_NETWORK` environment variable controls which network and canister to use. It is configured per environment in `icp.yaml`. -The canister derives a P2WPKH Bitcoin address from its threshold ECDSA key: +## Prerequisites -```bash -icp canister call backend get_canister_btc_address '()' -``` +- [icp-cli](https://github.com/dfinity/icp-cli) installed +- [Docker](https://docs.docker.com/get-docker/) installed and running (optional, but recommended) -This returns a `bcrt1q...` address (regtest SegWit format). Save it for the next steps: +> **Note:** Docker is used in this guide to run `bitcoind` for simplicity. If you prefer, you can install and run `bitcoind` natively instead — just make sure it is listening on the same ports (`18443` for RPC, `18444` for P2P) with the same credentials. -```bash -CANISTER_BTC_ADDR=$(icp canister call backend get_canister_btc_address '()' | grep -o '"[^"]*"' | tr -d '"') -echo "$CANISTER_BTC_ADDR" -``` - -## Step 3: Fund the Canister (Mine Blocks) +## Getting Started -On regtest, you fund addresses by mining blocks to them. Bitcoin requires **100 confirmations** before coinbase rewards are spendable, so mine 101 blocks: +Start a Bitcoin regtest node: ```bash -docker compose -p icp-local exec bitcoind \ - bitcoin-cli -regtest \ +docker run -d --name bitcoind \ + -p 18443:18443 -p 18444:18444 \ + lncm/bitcoind:v27.2 \ + -regtest -server -rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0 \ -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 101 "$CANISTER_BTC_ADDR" + -fallbackfee=0.00001 -txindex=1 ``` -> **Note:** `icp network start` runs the compose project under the name `icp-local`, so use `-p icp-local` instead of `-f docker-compose.bitcoin.yml` when interacting with running services. - -This gives the canister 50 BTC (the block reward from the first block, now mature after 100 confirmations). - -## Step 4: Check the Balance - -Query the canister's balance (in satoshis): +Start the local IC network and deploy: ```bash -icp canister call backend get_balance "(\"$CANISTER_BTC_ADDR\")" +icp network start -d +icp deploy ``` -You should see `5_000_000_000` (50 BTC = 5 billion satoshis). +## Usage -You can also view the UTXOs: +Verify the configured network and Bitcoin canister ID: ```bash -icp canister call backend get_utxos "(\"$CANISTER_BTC_ADDR\")" +icp canister call backend get_config '()' ``` -## Step 5: Transfer BTC - -Create a wallet in bitcoind, generate a destination address, and transfer BTC to it: +Create a wallet and get a Bitcoin address: ```bash -# Create a wallet in bitcoind (needed once, Bitcoin Core doesn't auto-create one) -docker compose -p icp-local exec bitcoind \ - bitcoin-cli -regtest \ +docker exec bitcoind bitcoin-cli -regtest \ -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ createwallet "default" -# Create a destination address -DEST_ADDR=$(docker compose -p icp-local exec bitcoind \ - bitcoin-cli -regtest \ +ADDR=$(docker exec bitcoind bitcoin-cli -regtest \ -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ getnewaddress) - -# Transfer 1 BTC (100,000,000 satoshis) -icp canister call backend transfer_btc \ - "(record { destination = \"$DEST_ADDR\"; amount_in_satoshi = 100_000_000 : nat64 })" ``` -This returns the transaction ID. - -## Step 6: Confirm and Verify - -Mine a block to confirm the transaction: +Check the balance (should be 0): ```bash -docker compose -p icp-local exec bitcoind \ - bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$CANISTER_BTC_ADDR" +icp canister call backend get_balance "(\"$ADDR\")" ``` -Check the updated balances: +Mine a block to the address (each block rewards 50 BTC): ```bash -# Canister balance (should be reduced by ~1 BTC + fee) -icp canister call backend get_balance "(\"$CANISTER_BTC_ADDR\")" - -# Destination balance -icp canister call backend get_balance "(\"$DEST_ADDR\")" +docker exec bitcoind bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" ``` -## Canister API - -| Function | Type | Description | -|----------|------|-------------| -| `get_canister_btc_address` | update | Returns a Bitcoin address controlled by the canister | -| `get_balance(address)` | update | Returns the balance in satoshis | -| `get_utxos(address)` | update | Returns the UTXOs for an address | -| `get_fee_percentiles` | update | Returns current fee percentiles (millisatoshi/vbyte) | -| `transfer_btc({destination, amount_in_satoshi})` | update | Sends BTC and returns the transaction ID | -| `get_bitcoin_info` | query | Returns the configured Bitcoin network | +Check the balance again (should be 5,000,000,000 satoshis = 50 BTC): -## Configuration +> **Note:** Coinbase rewards require 100 confirmations before they can be spent. If you extend this example to send transactions, mine at least 101 blocks so the first block's reward becomes spendable. -### Network Configuration - -The `icp.yaml` defines the local network using Docker Compose: - -```yaml -networks: - - name: local - mode: managed - compose: - file: docker-compose.bitcoin.yml - gateway-service: icp-network +```bash +icp canister call backend get_balance "(\"$ADDR\")" ``` -This tells icp-cli to use Docker Compose to manage the local network, starting both the Bitcoin regtest node and the IC replica together. +## Cleanup -### Bitcoin Network Selection +```bash +icp network stop +docker stop bitcoind && docker rm bitcoind +``` -The backend canister reads the `BITCOIN_NETWORK` environment variable to determine which Bitcoin network to use: +## Environments -- `regtest` (default) — Local regtest network via Docker Compose -- `testnet` — Bitcoin testnet -- `mainnet` — Bitcoin mainnet +| Environment | IC Network | Bitcoin Network | Usage | +|-------------|-----------|----------------|-------| +| `local` | Local (PocketIC) | regtest | `icp deploy` | +| `staging` | IC mainnet | testnet | `icp deploy --env staging` | +| `production` | IC mainnet | mainnet | `icp deploy --env production` | -The environment variable is configured in the environments section of `icp.yaml`: +## Cycle Costs -```yaml -environments: - - name: local - network: local - settings: - backend: - environment_variables: - BITCOIN_NETWORK: "regtest" -``` +Bitcoin canister API calls require cycles. The canister must attach cycles when calling the Bitcoin canister — the Rust CDK handles this automatically, while the Motoko backend attaches them explicitly via `(with cycles = amount)`. -## Project Structure +| API Call | Testnet / Regtest | Mainnet | +|----------|-------------------|---------| +| `bitcoin_get_balance` | 40,000,000 | 100,000,000 | +| `bitcoin_get_utxos` | 4,000,000,000 | 10,000,000,000 | +| `bitcoin_send_transaction` | 2,000,000,000 | 5,000,000,000 | -``` -. -├── icp.yaml # Project configuration -├── docker-compose.bitcoin.yml # Local Bitcoin + IC network setup -├── backend/ -│ ├── canister.yaml # Canister build configuration -│ ├── src/ -│ │ └── lib.rs # Backend canister code -│ └── backend.did # Candid interface -└── README.md -``` +See [Bitcoin API costs](https://docs.internetcomputer.org/references/bitcoin-how-it-works) for the full reference. ## Learn More +- [Bitcoin Canister API Specification](https://github.com/dfinity/bitcoin-canister/blob/master/INTERFACE_SPECIFICATION.md) — full API reference (get_utxos, send_transaction, fee percentiles, etc.) - [Internet Computer Bitcoin Integration](https://internetcomputer.org/docs/building-apps/bitcoin/overview) - [icp-cli Documentation](https://github.com/dfinity/icp-cli) -- [Bitcoin Regtest Mode](https://developer.bitcoin.org/examples/testing.html) diff --git a/bitcoin/cargo-generate.toml b/bitcoin/cargo-generate.toml index 0f533e6..6a21161 100644 --- a/bitcoin/cargo-generate.toml +++ b/bitcoin/cargo-generate.toml @@ -1,8 +1,9 @@ [template] -description = "ICP project with local Bitcoin integration using Docker Compose" +description = "ICP project with local Bitcoin integration" [placeholders] backend_type = { type = "string", prompt = "Choose your backend language:", choices = ["rust", "motoko"], default = "rust" } +network_type = { type = "string", prompt = "Use the default network or a dockerized one?", choices = ["Default", "Docker"], default = "Default" } [conditional.'backend_type == "rust"'] ignore = [ "motoko-backend" ] diff --git a/bitcoin/docker-compose.bitcoin.yml b/bitcoin/docker-compose.bitcoin.yml deleted file mode 100644 index 8c72244..0000000 --- a/bitcoin/docker-compose.bitcoin.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Docker Compose setup for local ICP development with Bitcoin integration -# -# This compose file runs: -# - bitcoind: Bitcoin Core in regtest mode for local testing -# - icp-network: IC network launcher with Bitcoin subnet support -# -# The network launcher connects to bitcoind and provides a local IC replica -# with native Bitcoin integration capabilities. - -services: - # Bitcoin Core node running in regtest mode - bitcoind: - image: lncm/bitcoind:v27.2 - command: - - -regtest - - -server - - -rpcbind=0.0.0.0 - - -rpcallowip=0.0.0.0/0 - - -rpcuser=ic-btc-integration - - -rpcpassword=ic-btc-integration - - -fallbackfee=0.00001 - - -txindex=1 - ports: - - "18443:18443" # RPC port - - "18444:18444" # P2P port - healthcheck: - test: ["CMD", "bitcoin-cli", "-regtest", "-rpcuser=ic-btc-integration", "-rpcpassword=ic-btc-integration", "getblockchaininfo"] - interval: 5s - timeout: 5s - retries: 20 - - # IC Network launcher with Bitcoin subnet - icp-network: - image: ghcr.io/dfinity/icp-cli-network-launcher:latest - depends_on: - bitcoind: - condition: service_healthy - environment: - - ICP_CLI_NETWORK_LAUNCHER_INTERFACE_VERSION=1.0.0 - command: - - --bitcoind-addr=bitcoind:18444 - ports: - - "8000:4943" # IC gateway port (use 0 for a random host port) - volumes: - - "${ICP_STATUS_DIR:-/tmp/icp-status}:/app/status" diff --git a/bitcoin/icp.yaml b/bitcoin/icp.yaml index 2762bf0..2b4e517 100644 --- a/bitcoin/icp.yaml +++ b/bitcoin/icp.yaml @@ -4,13 +4,18 @@ canisters: - backend networks: - # Local network with Bitcoin integration via Docker Compose - name: local mode: managed - compose: - file: docker-compose.bitcoin.yml - gateway-service: icp-network - +{% if network_type == "Docker" -%} + image: ghcr.io/dfinity/icp-cli-network-launcher + port-mapping: + - 0:4943 + args: + - "--bitcoind-addr=host.docker.internal:18444" +{% else -%} + bitcoind-addr: + - "127.0.0.1:18444" +{% endif %} environments: - name: local network: local @@ -18,3 +23,17 @@ environments: backend: environment_variables: BITCOIN_NETWORK: "regtest" + + - name: staging + network: ic + settings: + backend: + environment_variables: + BITCOIN_NETWORK: "testnet" + + - name: production + network: ic + settings: + backend: + environment_variables: + BITCOIN_NETWORK: "mainnet" diff --git a/bitcoin/motoko-backend/backend.did b/bitcoin/motoko-backend/backend.did index f9ee1c9..6c70901 100644 --- a/bitcoin/motoko-backend/backend.did +++ b/bitcoin/motoko-backend/backend.did @@ -1,44 +1,13 @@ type satoshi = nat64; -type millisatoshi_per_vbyte = nat64; - type bitcoin_address = text; -type outpoint = record { - txid : blob; - vout : nat32; -}; - -type utxo = record { - outpoint : outpoint; - value : satoshi; - height : nat32; -}; - -type get_utxos_response = record { - utxos : vec utxo; - tip_block_hash : blob; - tip_height : nat32; - next_page : opt blob; -}; - -type bitcoin_info = record { +type bitcoin_config = record { network : text; + bitcoin_canister_id : text; }; service : { - // Query the balance of a Bitcoin address "get_balance" : (bitcoin_address) -> (satoshi); - - // Query the UTXOs for a Bitcoin address - "get_utxos" : (bitcoin_address) -> (get_utxos_response); - - // Get current fee percentiles - "get_fee_percentiles" : () -> (vec millisatoshi_per_vbyte); - - // Get Bitcoin network configuration info - "get_bitcoin_info" : () -> (bitcoin_info) query; - - // Simple greeting to verify the canister is working - "greet" : (text) -> (text) query; + "get_config" : () -> (bitcoin_config) query; } diff --git a/bitcoin/motoko-backend/canister.yaml b/bitcoin/motoko-backend/canister.yaml index 5264d2b..927641f 100644 --- a/bitcoin/motoko-backend/canister.yaml +++ b/bitcoin/motoko-backend/canister.yaml @@ -2,8 +2,7 @@ name: backend recipe: - type: "@dfinity/motoko" + type: "https://raw.githubusercontent.com/dfinity/icp-cli-recipes/refs/heads/main/recipes/motoko/recipe.hbs" configuration: main: src/main.mo - args: --incremental-gc candid: backend.did diff --git a/bitcoin/motoko-backend/mops.toml b/bitcoin/motoko-backend/mops.toml new file mode 100644 index 0000000..3646007 --- /dev/null +++ b/bitcoin/motoko-backend/mops.toml @@ -0,0 +1,5 @@ +[dependencies] +core = "1.0.0" + +[toolchain] +moc = "1.1.0" diff --git a/bitcoin/motoko-backend/src/main.mo b/bitcoin/motoko-backend/src/main.mo index 6696084..a738d65 100644 --- a/bitcoin/motoko-backend/src/main.mo +++ b/bitcoin/motoko-backend/src/main.mo @@ -1,18 +1,12 @@ -/// Bitcoin Integration Example +/// Minimal Bitcoin integration example for the Internet Computer. /// -/// This canister demonstrates basic Bitcoin integration on the Internet Computer. -/// It provides functions to: -/// - Check Bitcoin balance for any address -/// - Get UTXOs (Unspent Transaction Outputs) for any address -/// - Get current fee percentiles +/// Demonstrates reading Bitcoin balance via the Bitcoin canister API. import Prim "mo:⛔"; import Text "mo:core/Text"; persistent actor Backend { - // Types from the management canister Bitcoin API public type Satoshi = Nat64; - public type MillisatoshiPerVByte = Nat64; public type BitcoinAddress = Text; public type Network = { @@ -21,53 +15,25 @@ persistent actor Backend { #regtest; }; - public type Outpoint = { - txid : Blob; - vout : Nat32; - }; - - public type Utxo = { - outpoint : Outpoint; - value : Satoshi; - height : Nat32; - }; - - public type GetUtxosResponse = { - utxos : [Utxo]; - tip_block_hash : Blob; - tip_height : Nat32; - next_page : ?Blob; - }; - - public type BitcoinInfo = { + public type BitcoinConfig = { network : Text; + bitcoin_canister_id : Text; }; - // Management canister interface for Bitcoin - let management_canister : actor { + type BitcoinCanister = actor { bitcoin_get_balance : shared { address : BitcoinAddress; network : Network; min_confirmations : ?Nat32; } -> async Satoshi; + }; - bitcoin_get_utxos : shared { - address : BitcoinAddress; - network : Network; - filter : ?{ #min_confirmations : Nat32; #page : Blob }; - } -> async GetUtxosResponse; - - bitcoin_get_current_fee_percentiles : shared { - network : Network; - } -> async [MillisatoshiPerVByte]; - } = actor ("aaaaa-aa"); - - /// Get the Bitcoin network from the BITCOIN_NETWORK environment variable. - private func getNetwork() : Network { + // Resolved once at init/upgrade (actor body has system capability). + // Environment variables are set at deploy time, so this is safe. + transient let network : Network = do { switch (Prim.envVar("BITCOIN_NETWORK")) { case (?value) { - let networkStr = Text.toLower(value); - switch (networkStr) { + switch (Text.toLower(value)) { case ("mainnet") #mainnet; case ("testnet") #testnet; case _ #regtest; @@ -77,44 +43,43 @@ persistent actor Backend { }; }; - /// Get the balance of a Bitcoin address in satoshis. - public func get_balance(address : BitcoinAddress) : async Satoshi { - await management_canister.bitcoin_get_balance({ - address; - network = getNetwork(); - min_confirmations = null; - }); + transient let bitcoinCanisterId : Text = switch (network) { + case (#mainnet) "ghsi2-tqaaa-aaaan-aaaca-cai"; + case _ "g4xu7-jiaaa-aaaan-aaaaq-cai"; }; - /// Get the UTXOs for a Bitcoin address. - public func get_utxos(address : BitcoinAddress) : async GetUtxosResponse { - await management_canister.bitcoin_get_utxos({ - address; - network = getNetwork(); - filter = null; - }); + transient let networkText : Text = switch (network) { + case (#mainnet) "mainnet"; + case (#testnet) "testnet"; + case (#regtest) "regtest"; }; - /// Get the current Bitcoin fee percentiles. - public func get_fee_percentiles() : async [MillisatoshiPerVByte] { - await management_canister.bitcoin_get_current_fee_percentiles({ - network = getNetwork(); - }); + private func getBitcoinCanister() : BitcoinCanister { + actor (bitcoinCanisterId) : BitcoinCanister; }; - /// Get information about the Bitcoin canister configuration. - public query func get_bitcoin_info() : async BitcoinInfo { - let network = getNetwork(); - let networkText = switch (network) { - case (#mainnet) "Mainnet"; - case (#testnet) "Testnet"; - case (#regtest) "Regtest"; - }; - { network = networkText }; + // Minimum cycles required for bitcoin_get_balance + // (100M for mainnet, 40M for testnet/regtest). + // See https://docs.internetcomputer.org/references/bitcoin-how-it-works + transient let getBalanceCost : Nat = switch (network) { + case (#mainnet) 100_000_000; + case _ 40_000_000; + }; + + /// Get the balance of a Bitcoin address in satoshis. + public func get_balance(address : BitcoinAddress) : async Satoshi { + await (with cycles = getBalanceCost) getBitcoinCanister().bitcoin_get_balance({ + address; + network; + min_confirmations = null; + }); }; - /// Simple greeting function to verify the canister is working. - public query func greet(name : Text) : async Text { - "Hello, " # name # "! This canister supports Bitcoin integration."; + /// Get the canister's Bitcoin configuration. + public query func get_config() : async BitcoinConfig { + { + network = networkText; + bitcoin_canister_id = bitcoinCanisterId; + }; }; }; diff --git a/bitcoin/rust-backend/Cargo.toml b/bitcoin/rust-backend/Cargo.toml index 9237f40..d71393e 100644 --- a/bitcoin/rust-backend/Cargo.toml +++ b/bitcoin/rust-backend/Cargo.toml @@ -7,7 +7,5 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -bitcoin = "0.32" candid = "0.10" ic-cdk = "0.19" -serde = { version = "1.0", features = ["derive"] } diff --git a/bitcoin/rust-backend/backend.did b/bitcoin/rust-backend/backend.did index 17d7acb..6c70901 100644 --- a/bitcoin/rust-backend/backend.did +++ b/bitcoin/rust-backend/backend.did @@ -1,47 +1,13 @@ type satoshi = nat64; -type millisatoshi_per_vbyte = nat64; - type bitcoin_address = text; -type outpoint = record { - txid : blob; - vout : nat32; -}; - -type utxo = record { - outpoint : outpoint; - value : satoshi; - height : nat32; -}; - -type get_utxos_response = record { - utxos : vec utxo; - tip_block_hash : blob; - tip_height : nat32; - next_page : opt blob; -}; - -type bitcoin_info = record { +type bitcoin_config = record { network : text; + bitcoin_canister_id : text; }; service : { - // Derive a Bitcoin address controlled by this canister via threshold ECDSA - "get_canister_btc_address" : () -> (text); - - // Query the balance of a Bitcoin address "get_balance" : (bitcoin_address) -> (satoshi); - - // Query the UTXOs for a Bitcoin address - "get_utxos" : (bitcoin_address) -> (get_utxos_response); - - // Get current fee percentiles - "get_fee_percentiles" : () -> (vec millisatoshi_per_vbyte); - - // Transfer Bitcoin to a destination address, returns the transaction ID - "transfer_btc" : (record { destination : text; amount_in_satoshi : nat64 }) -> (text); - - // Get Bitcoin network configuration info - "get_bitcoin_info" : () -> (bitcoin_info) query; + "get_config" : () -> (bitcoin_config) query; } diff --git a/bitcoin/rust-backend/canister.yaml b/bitcoin/rust-backend/canister.yaml index 8faf89e..b83ec1b 100644 --- a/bitcoin/rust-backend/canister.yaml +++ b/bitcoin/rust-backend/canister.yaml @@ -2,7 +2,7 @@ name: backend recipe: - type: "@dfinity/rust" + type: "@dfinity/rust@v3.0.0" configuration: package: backend shrink: true diff --git a/bitcoin/rust-backend/src/lib.rs b/bitcoin/rust-backend/src/lib.rs index 32c6de3..17417e8 100644 --- a/bitcoin/rust-backend/src/lib.rs +++ b/bitcoin/rust-backend/src/lib.rs @@ -1,38 +1,12 @@ -//! Bitcoin Integration Example +//! Minimal Bitcoin integration example for the Internet Computer. //! -//! This canister demonstrates Bitcoin integration on the Internet Computer: -//! - Derive a Bitcoin address controlled by the canister via threshold ECDSA -//! - Receive Bitcoin by mining to the canister's address (on regtest) -//! - Send Bitcoin to any address -//! - Query balances and UTXOs -//! -//! The Bitcoin network is configured via the BITCOIN_NETWORK environment variable. +//! Demonstrates reading Bitcoin balance via the Bitcoin canister API. -use bitcoin::{ - absolute::LockTime, - consensus::serialize, - hashes::Hash, - sighash::{EcdsaSighashType, SighashCache}, - transaction::Version, - Address, Amount, CompressedPublicKey, Network as BtcNetwork, OutPoint, ScriptBuf, Sequence, - Transaction, TxIn, TxOut, Txid, Witness, -}; use candid::CandidType; use ic_cdk::bitcoin_canister::{ - bitcoin_get_balance, bitcoin_get_current_fee_percentiles, bitcoin_get_utxos, - bitcoin_send_transaction, GetBalanceRequest, GetCurrentFeePercentilesRequest, GetUtxosRequest, - GetUtxosResponse, MillisatoshiPerByte, Network, Satoshi, SendTransactionRequest, Utxo, + bitcoin_get_balance, get_bitcoin_canister_id, GetBalanceRequest, Network, Satoshi, }; -use ic_cdk::management_canister::{EcdsaCurve, EcdsaKeyId, EcdsaPublicKeyArgs, SignWithEcdsaArgs}; -use serde::Deserialize; - -const DUST_THRESHOLD: u64 = 1_000; -// --------------------------------------------------------------------------- -// Bitcoin network helpers -// --------------------------------------------------------------------------- - -/// Get the IC Bitcoin network from the BITCOIN_NETWORK environment variable. fn get_network() -> Network { let network_str = if ic_cdk::api::env_var_name_exists("BITCOIN_NETWORK") { ic_cdk::api::env_var_value("BITCOIN_NETWORK").to_lowercase() @@ -47,235 +21,6 @@ fn get_network() -> Network { } } -/// Map the IC network type to the rust-bitcoin network type. -fn to_btc_network(network: Network) -> BtcNetwork { - match network { - Network::Mainnet => BtcNetwork::Bitcoin, - Network::Testnet => BtcNetwork::Testnet, - Network::Regtest => BtcNetwork::Regtest, - } -} - -/// The ECDSA key name used by the IC subnet. -fn ecdsa_key_name() -> String { - match get_network() { - Network::Regtest | Network::Testnet => "test_key_1".to_string(), - Network::Mainnet => "key_1".to_string(), - } -} - -fn ecdsa_key_id() -> EcdsaKeyId { - EcdsaKeyId { - curve: EcdsaCurve::Secp256k1, - name: ecdsa_key_name(), - } -} - -/// A fixed derivation path for the canister's Bitcoin key. -fn derivation_path() -> Vec> { - vec![b"btc".to_vec()] -} - -// --------------------------------------------------------------------------- -// Address derivation -// --------------------------------------------------------------------------- - -/// Fetch the canister's compressed ECDSA public key from the IC management canister. -async fn get_ecdsa_public_key() -> Vec { - ic_cdk::management_canister::ecdsa_public_key(&EcdsaPublicKeyArgs { - canister_id: None, - derivation_path: derivation_path(), - key_id: ecdsa_key_id(), - }) - .await - .expect("Failed to get ECDSA public key") - .public_key -} - -/// Derive the canister's P2WPKH Bitcoin address from its ECDSA public key. -async fn get_p2wpkh_address() -> Address { - let public_key_bytes = get_ecdsa_public_key().await; - let compressed_key = CompressedPublicKey::from_slice(&public_key_bytes) - .expect("Invalid 33-byte compressed public key"); - Address::p2wpkh(&compressed_key, to_btc_network(get_network())) -} - -// --------------------------------------------------------------------------- -// Transaction building -// --------------------------------------------------------------------------- - -/// Select UTXOs greedily to cover `amount + fee`. -fn select_utxos(utxos: &[Utxo], amount: u64, fee: u64) -> Vec { - let target = amount + fee; - let mut selected = Vec::new(); - let mut total = 0u64; - for utxo in utxos.iter().rev() { - selected.push(utxo.clone()); - total += utxo.value; - if total >= target { - return selected; - } - } - panic!( - "Insufficient balance: have {} satoshi, need {} (amount {} + fee {})", - total, target, amount, fee - ); -} - -/// Build a transaction spending `utxos_to_spend` with one output to `dst_address` -/// and an optional change output back to `own_address`. -fn build_transaction( - utxos_to_spend: &[Utxo], - own_address: &Address, - dst_address: &Address, - amount: u64, - fee: u64, -) -> Transaction { - let inputs: Vec = utxos_to_spend - .iter() - .map(|utxo| TxIn { - previous_output: OutPoint { - txid: Txid::from_raw_hash(Hash::from_slice(&utxo.outpoint.txid).unwrap()), - vout: utxo.outpoint.vout, - }, - sequence: Sequence::MAX, - script_sig: ScriptBuf::new(), - witness: Witness::new(), - }) - .collect(); - - let mut outputs = vec![TxOut { - value: Amount::from_sat(amount), - script_pubkey: dst_address.script_pubkey(), - }]; - - let total_in: u64 = utxos_to_spend.iter().map(|u| u.value).sum(); - let change = total_in - amount - fee; - if change >= DUST_THRESHOLD { - outputs.push(TxOut { - value: Amount::from_sat(change), - script_pubkey: own_address.script_pubkey(), - }); - } - - Transaction { - version: Version::TWO, - lock_time: LockTime::ZERO, - input: inputs, - output: outputs, - } -} - -/// Estimate the transaction fee using iterative sizing with mock signatures. -fn estimate_fee( - utxos: &[Utxo], - own_address: &Address, - dst_address: &Address, - amount: u64, - fee_per_vbyte: u64, -) -> (Vec, Transaction, u64) { - let mut fee = 0u64; - loop { - let selected = select_utxos(utxos, amount, fee); - let tx = build_transaction(&selected, own_address, dst_address, amount, fee); - - // Create a mock-signed copy to measure the virtual size. - let signed = mock_sign_transaction(tx.clone()); - let vsize = signed.vsize() as u64; - let new_fee = (vsize * fee_per_vbyte) / 1000; - - if new_fee == fee { - return (selected, tx, fee); - } - fee = new_fee; - } -} - -/// Fill in witness data with dummy signatures for size estimation. -fn mock_sign_transaction(mut tx: Transaction) -> Transaction { - let mock_sig = [1u8; 64]; - let mock_pubkey = [2u8; 33]; - - for input in tx.input.iter_mut() { - let mut witness = Witness::new(); - // A DER-encoded ECDSA signature is at most 73 bytes + 1 sighash byte. - // Using a compact 64-byte representation here slightly underestimates, - // but the iterative loop will converge to the correct fee regardless. - witness.push(mock_sig); - witness.push(mock_pubkey); - input.witness = witness; - } - - tx -} - -/// Sign each transaction input with the canister's threshold ECDSA key (P2WPKH). -async fn sign_transaction( - mut tx: Transaction, - utxos_to_spend: &[Utxo], - own_address: &Address, - public_key_bytes: &[u8], -) -> Transaction { - let compressed_key = - CompressedPublicKey::from_slice(public_key_bytes).expect("Invalid compressed public key"); - - // Build the prevouts list (needed for SegWit sighash computation). - let prevouts: Vec = utxos_to_spend - .iter() - .map(|utxo| TxOut { - value: Amount::from_sat(utxo.value), - script_pubkey: own_address.script_pubkey(), - }) - .collect(); - - for index in 0..tx.input.len() { - let sighash = { - let mut cache = SighashCache::new(&tx); - cache - .p2wpkh_signature_hash( - index, - &prevouts[index].script_pubkey, - prevouts[index].value, - EcdsaSighashType::All, - ) - .expect("Failed to compute sighash") - }; - - let raw_signature = ic_cdk::management_canister::sign_with_ecdsa(&SignWithEcdsaArgs { - message_hash: sighash.as_byte_array().to_vec(), - derivation_path: derivation_path(), - key_id: ecdsa_key_id(), - }) - .await - .expect("Failed to sign with ECDSA") - .signature; - - let signature = bitcoin::secp256k1::ecdsa::Signature::from_compact(&raw_signature) - .expect("Invalid ECDSA signature"); - let bitcoin_sig = bitcoin::ecdsa::Signature { - signature, - sighash_type: EcdsaSighashType::All, - }; - - let mut witness = Witness::new(); - witness.push(bitcoin_sig.to_vec()); - witness.push(compressed_key.to_bytes()); - tx.input[index].witness = witness; - } - - tx -} - -// --------------------------------------------------------------------------- -// Public canister API -// --------------------------------------------------------------------------- - -/// Returns a Bitcoin address controlled by this canister via threshold ECDSA. -#[ic_cdk::update] -async fn get_canister_btc_address() -> String { - get_p2wpkh_address().await.to_string() -} - /// Get the balance of a Bitcoin address in satoshis. #[ic_cdk::update] async fn get_balance(address: String) -> Satoshi { @@ -288,114 +33,23 @@ async fn get_balance(address: String) -> Satoshi { .expect("Failed to get balance") } -/// Get the UTXOs for a Bitcoin address. -#[ic_cdk::update] -async fn get_utxos(address: String) -> GetUtxosResponse { - bitcoin_get_utxos(&GetUtxosRequest { - address, - network: get_network(), - filter: None, - }) - .await - .expect("Failed to get UTXOs") -} - -/// Get current Bitcoin fee percentiles (millisatoshi per vbyte). -#[ic_cdk::update] -async fn get_fee_percentiles() -> Vec { - bitcoin_get_current_fee_percentiles(&GetCurrentFeePercentilesRequest { - network: get_network(), - }) - .await - .expect("Failed to get fee percentiles") -} - -/// Transfer Bitcoin to a destination address. -/// -/// Returns the transaction ID of the submitted transaction. -#[ic_cdk::update] -async fn transfer_btc(request: TransferRequest) -> String { - let network = get_network(); - let btc_network = to_btc_network(network); - - // Parse and validate destination address. - let dst_address: Address = request - .destination - .parse::>() - .expect("Invalid destination address") - .require_network(btc_network) - .expect("Destination address does not match the configured Bitcoin network"); - - // Get the canister's own address and public key. - let public_key_bytes = get_ecdsa_public_key().await; - let compressed_key = - CompressedPublicKey::from_slice(&public_key_bytes).expect("Invalid compressed public key"); - let own_address = Address::p2wpkh(&compressed_key, btc_network); - - // Fetch UTXOs for the canister's address. - let utxos_response = bitcoin_get_utxos(&GetUtxosRequest { - address: own_address.to_string(), - network, - filter: None, - }) - .await - .expect("Failed to get UTXOs"); - - // Determine fee rate (median of fee percentiles, fallback for regtest). - let fee_percentiles = - bitcoin_get_current_fee_percentiles(&GetCurrentFeePercentilesRequest { network }) - .await - .expect("Failed to get fee percentiles"); - let fee_per_vbyte = if fee_percentiles.is_empty() { - 2000 // fallback: 2 sat/vbyte in millisatoshis - } else { - fee_percentiles[fee_percentiles.len() / 2] - }; - - // Build and sign the transaction. - let (selected_utxos, unsigned_tx, _fee) = estimate_fee( - &utxos_response.utxos, - &own_address, - &dst_address, - request.amount_in_satoshi, - fee_per_vbyte, - ); - - let signed_tx = - sign_transaction(unsigned_tx, &selected_utxos, &own_address, &public_key_bytes).await; - - let txid = signed_tx.compute_txid().to_string(); - - // Broadcast the transaction. - bitcoin_send_transaction(&SendTransactionRequest { - network, - transaction: serialize(&signed_tx), - }) - .await - .expect("Failed to send transaction"); - - txid -} - -/// Get information about the Bitcoin canister configuration. +/// Get the canister's Bitcoin configuration. #[ic_cdk::query] -fn get_bitcoin_info() -> BitcoinInfo { - BitcoinInfo { - network: format!("{:?}", get_network()), +fn get_config() -> BitcoinConfig { + let network = get_network(); + BitcoinConfig { + network: match network { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + Network::Regtest => "regtest", + } + .to_string(), + bitcoin_canister_id: get_bitcoin_canister_id(&network).to_string(), } } -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -#[derive(CandidType, Deserialize)] -struct TransferRequest { - destination: String, - amount_in_satoshi: u64, -} - -#[derive(CandidType, Deserialize)] -struct BitcoinInfo { +#[derive(CandidType)] +struct BitcoinConfig { network: String, + bitcoin_canister_id: String, } From 93b2cfe5e7a9591403e2acc4832dfa52d742f89b Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 9 Feb 2026 21:10:56 +0100 Subject: [PATCH 03/13] chore(deps): use core v2.0.0 --- bitcoin/motoko-backend/mops.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitcoin/motoko-backend/mops.toml b/bitcoin/motoko-backend/mops.toml index 3646007..4ae1a0d 100644 --- a/bitcoin/motoko-backend/mops.toml +++ b/bitcoin/motoko-backend/mops.toml @@ -1,5 +1,5 @@ [dependencies] -core = "1.0.0" +core = "2.0.0" [toolchain] moc = "1.1.0" From f892ad2bad1032900024b3ecd6bc2ced1c4606a1 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 9 Feb 2026 21:11:54 +0100 Subject: [PATCH 04/13] fix: indentation issue --- bitcoin/icp.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bitcoin/icp.yaml b/bitcoin/icp.yaml index 2b4e517..0791160 100644 --- a/bitcoin/icp.yaml +++ b/bitcoin/icp.yaml @@ -3,16 +3,19 @@ canisters: - backend +{% if network_type == "Docker" -%} networks: - name: local mode: managed -{% if network_type == "Docker" -%} image: ghcr.io/dfinity/icp-cli-network-launcher port-mapping: - 0:4943 args: - "--bitcoind-addr=host.docker.internal:18444" {% else -%} +networks: + - name: local + mode: managed bitcoind-addr: - "127.0.0.1:18444" {% endif %} From 78aacfc44ce1927f3e2887881177c5153134b69a Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 26 Feb 2026 13:26:55 +0100 Subject: [PATCH 05/13] chore: update schema refs, recipe versions, and gitignore - Update YAML schema references from v0.1.0 to v0.2.0 - Use published Motoko recipe @dfinity/motoko@v4.1.0 - Update Rust recipe to @dfinity/rust@v3.2.0 - Expand .gitignore to match repo conventions --- bitcoin/.gitignore | 17 ++++++++++++++++- bitcoin/icp.yaml | 2 +- bitcoin/motoko-backend/canister.yaml | 4 ++-- bitcoin/rust-backend/canister.yaml | 4 ++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/bitcoin/.gitignore b/bitcoin/.gitignore index d39e572..ede6f2b 100644 --- a/bitcoin/.gitignore +++ b/bitcoin/.gitignore @@ -1,2 +1,17 @@ -.icp/cache/ +# Various IDEs and Editors +.vscode/ +.idea/ +**/*~ + +# Mac OSX temporary files +.DS_Store +**/.DS_Store + +# environment variables +.env + +# rust target/ + +# icp-cli +.icp/cache/ diff --git a/bitcoin/icp.yaml b/bitcoin/icp.yaml index 0791160..434021a 100644 --- a/bitcoin/icp.yaml +++ b/bitcoin/icp.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/icp-yaml-schema.json +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.2.0/docs/schemas/icp-yaml-schema.json canisters: - backend diff --git a/bitcoin/motoko-backend/canister.yaml b/bitcoin/motoko-backend/canister.yaml index 927641f..3876983 100644 --- a/bitcoin/motoko-backend/canister.yaml +++ b/bitcoin/motoko-backend/canister.yaml @@ -1,8 +1,8 @@ -# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/canister-yaml-schema.json +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.2.0/docs/schemas/canister-yaml-schema.json name: backend recipe: - type: "https://raw.githubusercontent.com/dfinity/icp-cli-recipes/refs/heads/main/recipes/motoko/recipe.hbs" + type: "@dfinity/motoko@v4.1.0" configuration: main: src/main.mo candid: backend.did diff --git a/bitcoin/rust-backend/canister.yaml b/bitcoin/rust-backend/canister.yaml index b83ec1b..0c7891a 100644 --- a/bitcoin/rust-backend/canister.yaml +++ b/bitcoin/rust-backend/canister.yaml @@ -1,8 +1,8 @@ -# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/canister-yaml-schema.json +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.2.0/docs/schemas/canister-yaml-schema.json name: backend recipe: - type: "@dfinity/rust@v3.0.0" + type: "@dfinity/rust@v3.2.0" configuration: package: backend shrink: true From 23687f1f1142ed1b82b44aa76d3664e2e6eab6d3 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 26 Feb 2026 13:31:56 +0100 Subject: [PATCH 06/13] docs: add bitcoin template to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ef60434..b7b6bf5 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ icp new --subfolder | Template | Description | | --- | --- | +| [bitcoin](./bitcoin/) | Bitcoin integration with balance reading (Rust or Motoko) | | [motoko](./motoko/) | A basic Motoko canister | | [rust](./rust/) | A basic Rust canister | | [hello-world](./hello-world/) | Full-stack dapp with a frontend and backend canister (Rust or Motoko) | From 0b1bcf9bbe302ce96631b4e8dd2405ec046b5647 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 26 Feb 2026 13:33:55 +0100 Subject: [PATCH 07/13] docs: reorder template list in README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b7b6bf5..2e39da2 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,12 @@ icp new --subfolder | Template | Description | | --- | --- | -| [bitcoin](./bitcoin/) | Bitcoin integration with balance reading (Rust or Motoko) | +| [hello-world](./hello-world/) | Full-stack dapp with a frontend and backend canister (Rust or Motoko) | | [motoko](./motoko/) | A basic Motoko canister | | [rust](./rust/) | A basic Rust canister | -| [hello-world](./hello-world/) | Full-stack dapp with a frontend and backend canister (Rust or Motoko) | -| [static-website](./static-website/) | A static website deployed to an asset canister | +| [bitcoin](./bitcoin/) | Bitcoin integration with balance reading (Rust or Motoko) | | [proxy](./proxy/) | A pre-built proxy canister that forwards HTTP requests to external URLs | +| [static-website](./static-website/) | A static website deployed to an asset canister | ## Contributing From b420fc92d799f7749e2f43c63bbd106e83b7ce50 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 5 Mar 2026 10:06:46 +0100 Subject: [PATCH 08/13] refactor(motoko): use Runtime.envVar from mo:core 2.1.0 Replace Prim.envVar with Runtime.envVar from mo:core/Runtime, removing the need for the internal Prim import. Bump moc to 1.3.0. --- bitcoin/motoko-backend/mops.toml | 4 ++-- bitcoin/motoko-backend/src/main.mo | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bitcoin/motoko-backend/mops.toml b/bitcoin/motoko-backend/mops.toml index 4ae1a0d..7a68148 100644 --- a/bitcoin/motoko-backend/mops.toml +++ b/bitcoin/motoko-backend/mops.toml @@ -1,5 +1,5 @@ [dependencies] -core = "2.0.0" +core = "2.1.0" [toolchain] -moc = "1.1.0" +moc = "1.3.0" diff --git a/bitcoin/motoko-backend/src/main.mo b/bitcoin/motoko-backend/src/main.mo index a738d65..36c0dca 100644 --- a/bitcoin/motoko-backend/src/main.mo +++ b/bitcoin/motoko-backend/src/main.mo @@ -2,7 +2,7 @@ /// /// Demonstrates reading Bitcoin balance via the Bitcoin canister API. -import Prim "mo:⛔"; +import Runtime "mo:core/Runtime"; import Text "mo:core/Text"; persistent actor Backend { @@ -31,7 +31,7 @@ persistent actor Backend { // Resolved once at init/upgrade (actor body has system capability). // Environment variables are set at deploy time, so this is safe. transient let network : Network = do { - switch (Prim.envVar("BITCOIN_NETWORK")) { + switch (Runtime.envVar("BITCOIN_NETWORK")) { case (?value) { switch (Text.toLower(value)) { case ("mainnet") #mainnet; From dcf808c1185ddf11260d8a473ff1e7bea361331c Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 5 Mar 2026 10:17:49 +0100 Subject: [PATCH 09/13] docs: update bitcoin README links --- bitcoin/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bitcoin/README.md b/bitcoin/README.md index f746756..ae1da38 100644 --- a/bitcoin/README.md +++ b/bitcoin/README.md @@ -106,10 +106,9 @@ Bitcoin canister API calls require cycles. The canister must attach cycles when | `bitcoin_get_utxos` | 4,000,000,000 | 10,000,000,000 | | `bitcoin_send_transaction` | 2,000,000,000 | 5,000,000,000 | -See [Bitcoin API costs](https://docs.internetcomputer.org/references/bitcoin-how-it-works) for the full reference. +See [Bitcoin API costs](https://docs.internetcomputer.org/references/bitcoin-how-it-works#api-fees-and-pricing) for the full reference. ## Learn More - [Bitcoin Canister API Specification](https://github.com/dfinity/bitcoin-canister/blob/master/INTERFACE_SPECIFICATION.md) — full API reference (get_utxos, send_transaction, fee percentiles, etc.) -- [Internet Computer Bitcoin Integration](https://internetcomputer.org/docs/building-apps/bitcoin/overview) -- [icp-cli Documentation](https://github.com/dfinity/icp-cli) +- [Developer Docs](https://docs.internetcomputer.org/build-on-btc) - How to build on Bitcoin From 80bea43edacd71fb297bba2b6346242dc9488105 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 5 Mar 2026 12:23:05 +0100 Subject: [PATCH 10/13] refactor: read BITCOIN_NETWORK env var on every call Align Motoko with Rust by reading the env var per request instead of caching at init. Add comment to both backends noting the network can be changed at runtime. --- bitcoin/motoko-backend/src/main.mo | 44 ++++++++++++++++-------------- bitcoin/rust-backend/src/lib.rs | 2 ++ 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/bitcoin/motoko-backend/src/main.mo b/bitcoin/motoko-backend/src/main.mo index 36c0dca..68cac73 100644 --- a/bitcoin/motoko-backend/src/main.mo +++ b/bitcoin/motoko-backend/src/main.mo @@ -28,9 +28,9 @@ persistent actor Backend { } -> async Satoshi; }; - // Resolved once at init/upgrade (actor body has system capability). - // Environment variables are set at deploy time, so this is safe. - transient let network : Network = do { + // The BITCOIN_NETWORK env var (and thus the targeted Bitcoin network) + // can be changed at runtime without redeploying the canister. + private func getNetwork() : Network { switch (Runtime.envVar("BITCOIN_NETWORK")) { case (?value) { switch (Text.toLower(value)) { @@ -43,32 +43,35 @@ persistent actor Backend { }; }; - transient let bitcoinCanisterId : Text = switch (network) { - case (#mainnet) "ghsi2-tqaaa-aaaan-aaaca-cai"; - case _ "g4xu7-jiaaa-aaaan-aaaaq-cai"; - }; - - transient let networkText : Text = switch (network) { - case (#mainnet) "mainnet"; - case (#testnet) "testnet"; - case (#regtest) "regtest"; + private func getBitcoinCanisterId(network : Network) : Text { + switch (network) { + case (#mainnet) "ghsi2-tqaaa-aaaan-aaaca-cai"; + case _ "g4xu7-jiaaa-aaaan-aaaaq-cai"; + }; }; - private func getBitcoinCanister() : BitcoinCanister { - actor (bitcoinCanisterId) : BitcoinCanister; + private func networkToText(network : Network) : Text { + switch (network) { + case (#mainnet) "mainnet"; + case (#testnet) "testnet"; + case (#regtest) "regtest"; + }; }; // Minimum cycles required for bitcoin_get_balance // (100M for mainnet, 40M for testnet/regtest). // See https://docs.internetcomputer.org/references/bitcoin-how-it-works - transient let getBalanceCost : Nat = switch (network) { - case (#mainnet) 100_000_000; - case _ 40_000_000; + private func getBalanceCost(network : Network) : Nat { + switch (network) { + case (#mainnet) 100_000_000; + case _ 40_000_000; + }; }; /// Get the balance of a Bitcoin address in satoshis. public func get_balance(address : BitcoinAddress) : async Satoshi { - await (with cycles = getBalanceCost) getBitcoinCanister().bitcoin_get_balance({ + let network = getNetwork(); + await (with cycles = getBalanceCost(network)) (actor (getBitcoinCanisterId(network)) : BitcoinCanister).bitcoin_get_balance({ address; network; min_confirmations = null; @@ -77,9 +80,10 @@ persistent actor Backend { /// Get the canister's Bitcoin configuration. public query func get_config() : async BitcoinConfig { + let network = getNetwork(); { - network = networkText; - bitcoin_canister_id = bitcoinCanisterId; + network = networkToText(network); + bitcoin_canister_id = getBitcoinCanisterId(network); }; }; }; diff --git a/bitcoin/rust-backend/src/lib.rs b/bitcoin/rust-backend/src/lib.rs index 17417e8..2b9c83f 100644 --- a/bitcoin/rust-backend/src/lib.rs +++ b/bitcoin/rust-backend/src/lib.rs @@ -7,6 +7,8 @@ use ic_cdk::bitcoin_canister::{ bitcoin_get_balance, get_bitcoin_canister_id, GetBalanceRequest, Network, Satoshi, }; +// The BITCOIN_NETWORK env var (and thus the targeted Bitcoin network) +// can be changed at runtime without redeploying the canister. fn get_network() -> Network { let network_str = if ic_cdk::api::env_var_name_exists("BITCOIN_NETWORK") { ic_cdk::api::env_var_value("BITCOIN_NETWORK").to_lowercase() From b653dee0efa00f19ee9e03a3693e6817a38687b7 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 5 Mar 2026 12:24:44 +0100 Subject: [PATCH 11/13] refactor: rename bitcoin template to bitcoin-starter --- README.md | 2 +- {bitcoin => bitcoin-starter}/.gitignore | 0 {bitcoin => bitcoin-starter}/README.md | 2 +- {bitcoin => bitcoin-starter}/cargo-generate.toml | 0 {bitcoin => bitcoin-starter}/icp.yaml | 0 {bitcoin => bitcoin-starter}/motoko-backend/.gitignore | 0 {bitcoin => bitcoin-starter}/motoko-backend/backend.did | 0 {bitcoin => bitcoin-starter}/motoko-backend/canister.yaml | 0 {bitcoin => bitcoin-starter}/motoko-backend/mops.toml | 0 {bitcoin => bitcoin-starter}/motoko-backend/src/main.mo | 0 {bitcoin => bitcoin-starter}/rename-backend-dir.rhai | 0 {bitcoin => bitcoin-starter}/rust-backend/.gitignore | 0 {bitcoin => bitcoin-starter}/rust-backend/Cargo.toml | 0 {bitcoin => bitcoin-starter}/rust-backend/backend.did | 0 {bitcoin => bitcoin-starter}/rust-backend/canister.yaml | 0 {bitcoin => bitcoin-starter}/rust-backend/src/lib.rs | 0 cargo-generate.toml | 2 +- 17 files changed, 3 insertions(+), 3 deletions(-) rename {bitcoin => bitcoin-starter}/.gitignore (100%) rename {bitcoin => bitcoin-starter}/README.md (99%) rename {bitcoin => bitcoin-starter}/cargo-generate.toml (100%) rename {bitcoin => bitcoin-starter}/icp.yaml (100%) rename {bitcoin => bitcoin-starter}/motoko-backend/.gitignore (100%) rename {bitcoin => bitcoin-starter}/motoko-backend/backend.did (100%) rename {bitcoin => bitcoin-starter}/motoko-backend/canister.yaml (100%) rename {bitcoin => bitcoin-starter}/motoko-backend/mops.toml (100%) rename {bitcoin => bitcoin-starter}/motoko-backend/src/main.mo (100%) rename {bitcoin => bitcoin-starter}/rename-backend-dir.rhai (100%) rename {bitcoin => bitcoin-starter}/rust-backend/.gitignore (100%) rename {bitcoin => bitcoin-starter}/rust-backend/Cargo.toml (100%) rename {bitcoin => bitcoin-starter}/rust-backend/backend.did (100%) rename {bitcoin => bitcoin-starter}/rust-backend/canister.yaml (100%) rename {bitcoin => bitcoin-starter}/rust-backend/src/lib.rs (100%) diff --git a/README.md b/README.md index 2e39da2..c95489f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ icp new --subfolder | [hello-world](./hello-world/) | Full-stack dapp with a frontend and backend canister (Rust or Motoko) | | [motoko](./motoko/) | A basic Motoko canister | | [rust](./rust/) | A basic Rust canister | -| [bitcoin](./bitcoin/) | Bitcoin integration with balance reading (Rust or Motoko) | +| [bitcoin-starter](./bitcoin-starter/) | Bitcoin integration with balance reading (Rust or Motoko) | | [proxy](./proxy/) | A pre-built proxy canister that forwards HTTP requests to external URLs | | [static-website](./static-website/) | A static website deployed to an asset canister | diff --git a/bitcoin/.gitignore b/bitcoin-starter/.gitignore similarity index 100% rename from bitcoin/.gitignore rename to bitcoin-starter/.gitignore diff --git a/bitcoin/README.md b/bitcoin-starter/README.md similarity index 99% rename from bitcoin/README.md rename to bitcoin-starter/README.md index ae1da38..e5b94fd 100644 --- a/bitcoin/README.md +++ b/bitcoin-starter/README.md @@ -1,4 +1,4 @@ -# Bitcoin Integration Template +# Bitcoin Starter Template Demonstrates reading Bitcoin balance from a canister on the Internet Computer using the [Bitcoin canister API](https://github.com/dfinity/bitcoin-canister/blob/master/INTERFACE_SPECIFICATION.md). diff --git a/bitcoin/cargo-generate.toml b/bitcoin-starter/cargo-generate.toml similarity index 100% rename from bitcoin/cargo-generate.toml rename to bitcoin-starter/cargo-generate.toml diff --git a/bitcoin/icp.yaml b/bitcoin-starter/icp.yaml similarity index 100% rename from bitcoin/icp.yaml rename to bitcoin-starter/icp.yaml diff --git a/bitcoin/motoko-backend/.gitignore b/bitcoin-starter/motoko-backend/.gitignore similarity index 100% rename from bitcoin/motoko-backend/.gitignore rename to bitcoin-starter/motoko-backend/.gitignore diff --git a/bitcoin/motoko-backend/backend.did b/bitcoin-starter/motoko-backend/backend.did similarity index 100% rename from bitcoin/motoko-backend/backend.did rename to bitcoin-starter/motoko-backend/backend.did diff --git a/bitcoin/motoko-backend/canister.yaml b/bitcoin-starter/motoko-backend/canister.yaml similarity index 100% rename from bitcoin/motoko-backend/canister.yaml rename to bitcoin-starter/motoko-backend/canister.yaml diff --git a/bitcoin/motoko-backend/mops.toml b/bitcoin-starter/motoko-backend/mops.toml similarity index 100% rename from bitcoin/motoko-backend/mops.toml rename to bitcoin-starter/motoko-backend/mops.toml diff --git a/bitcoin/motoko-backend/src/main.mo b/bitcoin-starter/motoko-backend/src/main.mo similarity index 100% rename from bitcoin/motoko-backend/src/main.mo rename to bitcoin-starter/motoko-backend/src/main.mo diff --git a/bitcoin/rename-backend-dir.rhai b/bitcoin-starter/rename-backend-dir.rhai similarity index 100% rename from bitcoin/rename-backend-dir.rhai rename to bitcoin-starter/rename-backend-dir.rhai diff --git a/bitcoin/rust-backend/.gitignore b/bitcoin-starter/rust-backend/.gitignore similarity index 100% rename from bitcoin/rust-backend/.gitignore rename to bitcoin-starter/rust-backend/.gitignore diff --git a/bitcoin/rust-backend/Cargo.toml b/bitcoin-starter/rust-backend/Cargo.toml similarity index 100% rename from bitcoin/rust-backend/Cargo.toml rename to bitcoin-starter/rust-backend/Cargo.toml diff --git a/bitcoin/rust-backend/backend.did b/bitcoin-starter/rust-backend/backend.did similarity index 100% rename from bitcoin/rust-backend/backend.did rename to bitcoin-starter/rust-backend/backend.did diff --git a/bitcoin/rust-backend/canister.yaml b/bitcoin-starter/rust-backend/canister.yaml similarity index 100% rename from bitcoin/rust-backend/canister.yaml rename to bitcoin-starter/rust-backend/canister.yaml diff --git a/bitcoin/rust-backend/src/lib.rs b/bitcoin-starter/rust-backend/src/lib.rs similarity index 100% rename from bitcoin/rust-backend/src/lib.rs rename to bitcoin-starter/rust-backend/src/lib.rs diff --git a/cargo-generate.toml b/cargo-generate.toml index a691ae7..b747dd0 100644 --- a/cargo-generate.toml +++ b/cargo-generate.toml @@ -1,7 +1,7 @@ [template] sub_templates = [ "hello-world", - "bitcoin", + "bitcoin-starter", "motoko", "proxy", "rust", From 9e00cd86a3aa0b9eab65fc37a929ea2fbc7a429e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 5 Mar 2026 12:28:42 +0100 Subject: [PATCH 12/13] fix(motoko): add system capability to getNetwork --- bitcoin-starter/motoko-backend/src/main.mo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitcoin-starter/motoko-backend/src/main.mo b/bitcoin-starter/motoko-backend/src/main.mo index 68cac73..2026219 100644 --- a/bitcoin-starter/motoko-backend/src/main.mo +++ b/bitcoin-starter/motoko-backend/src/main.mo @@ -30,7 +30,7 @@ persistent actor Backend { // The BITCOIN_NETWORK env var (and thus the targeted Bitcoin network) // can be changed at runtime without redeploying the canister. - private func getNetwork() : Network { + private func getNetwork() : Network { switch (Runtime.envVar("BITCOIN_NETWORK")) { case (?value) { switch (Text.toLower(value)) { From 2014bc090a071c2645632ecf064f46fd343e226a Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 5 Mar 2026 12:40:56 +0100 Subject: [PATCH 13/13] fix(motoko): cache network for query, read fresh for update Runtime.envVar requires system capability which is not available in Motoko query functions. Use a cached transient value for get_config (query) while get_balance (update) reads the env var fresh. Also clean up ordering to mirror the Rust backend: types, helpers, then public API. --- bitcoin-starter/motoko-backend/src/main.mo | 23 +++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/bitcoin-starter/motoko-backend/src/main.mo b/bitcoin-starter/motoko-backend/src/main.mo index 2026219..9dd528d 100644 --- a/bitcoin-starter/motoko-backend/src/main.mo +++ b/bitcoin-starter/motoko-backend/src/main.mo @@ -50,17 +50,9 @@ persistent actor Backend { }; }; - private func networkToText(network : Network) : Text { - switch (network) { - case (#mainnet) "mainnet"; - case (#testnet) "testnet"; - case (#regtest) "regtest"; - }; - }; - // Minimum cycles required for bitcoin_get_balance // (100M for mainnet, 40M for testnet/regtest). - // See https://docs.internetcomputer.org/references/bitcoin-how-it-works + // See https://docs.internetcomputer.org/references/bitcoin-how-it-works#api-fees-and-pricing private func getBalanceCost(network : Network) : Nat { switch (network) { case (#mainnet) 100_000_000; @@ -79,11 +71,18 @@ persistent actor Backend { }; /// Get the canister's Bitcoin configuration. + // Note: Runtime.envVar requires `system` capability which is not + // available in Motoko query functions, so we use a cached value here. + transient let cachedNetwork : Network = getNetwork(); + public query func get_config() : async BitcoinConfig { - let network = getNetwork(); { - network = networkToText(network); - bitcoin_canister_id = getBitcoinCanisterId(network); + network = switch (cachedNetwork) { + case (#mainnet) "mainnet"; + case (#testnet) "testnet"; + case (#regtest) "regtest"; + }; + bitcoin_canister_id = getBitcoinCanisterId(cachedNetwork); }; }; };