diff --git a/docs/guides/chain-fusion/bitcoin.mdx b/docs/guides/chain-fusion/bitcoin.mdx index 0a800573..0c00d185 100644 --- a/docs/guides/chain-fusion/bitcoin.mdx +++ b/docs/guides/chain-fusion/bitcoin.mdx @@ -390,34 +390,49 @@ async fn withdraw(btc_address: String, amount: u64) -> RetrieveBtcResult { ### Interact with ckBTC using icp-cli -You can call the ckBTC canisters directly from the command line: +First, export your principal from your active identity (every command below reuses it): + +```bash +export MY_PRINCIPAL=$(icp identity principal) +``` + +Get a deposit address: ```bash -# Get a BTC deposit address icp canister call mqygn-kiaaa-aaaar-qaadq-cai get_btc_address \ - '(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \ + "(record { owner = opt principal \"$MY_PRINCIPAL\"; subaccount = null })" \ -n ic +``` + +Check for new deposits and mint ckBTC: -# Check for new deposits and mint ckBTC +```bash icp canister call mqygn-kiaaa-aaaar-qaadq-cai update_balance \ - '(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \ + "(record { owner = opt principal \"$MY_PRINCIPAL\"; subaccount = null })" \ -n ic +``` -# Check ckBTC balance (amount in satoshis) +Check ckBTC balance (amount in satoshis): + +```bash icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ - '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ + "(record { owner = principal \"$MY_PRINCIPAL\"; subaccount = null })" \ -n ic +``` + +Transfer ckBTC (10 satoshi fee, amount in satoshis): -# Transfer ckBTC (10 satoshi fee) +```bash +export RECIPIENT="" icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer \ - '(record { - to = record { owner = principal "RECIPIENT"; subaccount = null }; + "(record { + to = record { owner = principal \"$RECIPIENT\"; subaccount = null }; amount = 100_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; - })' -n ic + })" -n ic ``` ### Common mistakes @@ -459,117 +474,7 @@ Signing uses threshold key derivation provided by the management canister: - `ecdsa_public_key` / `sign_with_ecdsa`: P2PKH, P2SH, and P2WPKH addresses - `schnorr_public_key` / `sign_with_schnorr`: Taproot (P2TR) addresses -All calls require cycles — see [Cycle costs](#cycle-costs). The `ic-cdk-bitcoin-canister` crate handles them automatically in Rust; in Motoko attach cycles explicitly with `(with cycles = amount)`. - -### Blockchain info - -`get_blockchain_info()` queries the state of the Bitcoin chain. The response includes: - -| Field | Type | Description | -|---|---|---| -| `height` | `nat32` | Current chain tip block height | -| `block_hash` | `blob` | Chain tip block hash | -| `timestamp` | `nat32` | Unix timestamp of the tip block | -| `difficulty` | `nat` | Current mining difficulty target | -| `utxos_length` | `nat64` | Total number of UTXOs in the UTXO set | - - - - -```motoko -import Runtime "mo:core/Runtime"; -import Text "mo:core/Text"; - -persistent actor Backend { - public type Network = { #mainnet; #testnet; #regtest }; - - type BlockchainInfo = { - height : Nat32; - block_hash : Blob; - timestamp : Nat32; - difficulty : Nat; - utxos_length : Nat64; - }; - - type BitcoinCanister = actor { - get_blockchain_info : shared () -> async BlockchainInfo; - }; - - // capability is required to access Runtime.envVar (reads environment variables at runtime) - private func getNetwork() : Network { - switch (Runtime.envVar("BITCOIN_NETWORK")) { - case (?value) { - switch (Text.toLower(value)) { - case ("mainnet") #mainnet; - case ("testnet") #testnet; - case _ #regtest; - }; - }; - case null #regtest; - }; - }; - - private func getBitcoinCanisterId(network : Network) : Text { - switch (network) { - case (#mainnet) "ghsi2-tqaaa-aaaan-aaaca-cai"; - case _ "g4xu7-jiaaa-aaaan-aaaaq-cai"; - }; - }; - - private func getBlockchainInfoCost(network : Network) : Nat { - switch (network) { - case (#mainnet) 100_000_000; - case _ 40_000_000; - }; - }; - - public func get_blockchain_info() : async BlockchainInfo { - let network = getNetwork(); - await (with cycles = getBlockchainInfoCost(network)) - (actor (getBitcoinCanisterId(network)) : BitcoinCanister) - .get_blockchain_info(); - }; -}; -``` - - - - -```rust -use ic_cdk_bitcoin_canister::{get_blockchain_info, BlockchainInfo, Network}; - -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, - } -} - -#[ic_cdk::update] -async fn get_blockchain_info_handler() -> BlockchainInfo { - get_blockchain_info(get_network()) - .await - .expect("Failed to get blockchain info") -} -``` - -Add to `Cargo.toml` alongside `ic-cdk`: - -```toml -ic-cdk-bitcoin-canister = "0.2" -``` - - - - -Full implementation: [basic_bitcoin example (Rust)](https://github.com/dfinity/examples/tree/master/rust/basic_bitcoin) — `src/service/get_blockchain_info.rs`. +All calls require cycles (see [Cycle costs](#cycle-costs)). The `ic-cdk-bitcoin-canister` crate handles them automatically in Rust; in Motoko attach cycles explicitly with `(with cycles = amount)`. ### Read Bitcoin balance @@ -763,11 +668,11 @@ async fn get_utxos(address: String) -> GetUtxosResponse { -`bitcoin_get_utxos` returns a `next_page` field. If non-null, the address has more UTXOs than fit in one response — call again with `filter = ?#page(next_page)` (Motoko) or `filter: Some(UtxosFilter::Page(next_page))` (Rust) until `next_page` is null. +`bitcoin_get_utxos` returns a `next_page` field. If non-null, the address has more UTXOs than fit in one response: call again with `filter = ?#page(next_page)` (Motoko) or `filter: Some(UtxosFilter::Page(next_page))` (Rust) until `next_page` is null. ### Get fee percentiles -Fee percentiles are measured in millisatoshi per vbyte (1,000 msat = 1 satoshi). The 50th percentile gives a reasonable median confirmation target. On regtest there are no transactions, so the response is empty — use a fallback. +Fee percentiles are measured in millisatoshi per vbyte (1,000 msat = 1 satoshi). The 50th percentile gives a reasonable median confirmation target. On regtest there are no transactions, so the response is empty: use a fallback. @@ -853,18 +758,134 @@ async fn get_fee_per_byte(network: Network) -> MillisatoshiPerByte { +### Blockchain info + +`get_blockchain_info()` queries the state of the Bitcoin chain. The response includes: + +| Field | Type | Description | +|---|---|---| +| `height` | `nat32` | Current chain tip block height | +| `block_hash` | `blob` | Chain tip block hash | +| `timestamp` | `nat32` | Unix timestamp of the tip block | +| `difficulty` | `nat` | Current mining difficulty target | +| `utxos_length` | `nat64` | Total number of UTXOs in the UTXO set | + + + + +```motoko +import Runtime "mo:core/Runtime"; +import Text "mo:core/Text"; + +persistent actor Backend { + public type Network = { #mainnet; #testnet; #regtest }; + + type BlockchainInfo = { + height : Nat32; + block_hash : Blob; + timestamp : Nat32; + difficulty : Nat; + utxos_length : Nat64; + }; + + type BitcoinCanister = actor { + get_blockchain_info : shared () -> async BlockchainInfo; + }; + + // capability is required to access Runtime.envVar (reads environment variables at runtime) + private func getNetwork() : Network { + switch (Runtime.envVar("BITCOIN_NETWORK")) { + case (?value) { + switch (Text.toLower(value)) { + case ("mainnet") #mainnet; + case ("testnet") #testnet; + case _ #regtest; + }; + }; + case null #regtest; + }; + }; + + private func getBitcoinCanisterId(network : Network) : Text { + switch (network) { + case (#mainnet) "ghsi2-tqaaa-aaaan-aaaca-cai"; + case _ "g4xu7-jiaaa-aaaan-aaaaq-cai"; + }; + }; + + private func getBlockchainInfoCost(network : Network) : Nat { + switch (network) { + case (#mainnet) 100_000_000; + case _ 40_000_000; + }; + }; + + public func get_blockchain_info() : async BlockchainInfo { + let network = getNetwork(); + await (with cycles = getBlockchainInfoCost(network)) + (actor (getBitcoinCanisterId(network)) : BitcoinCanister) + .get_blockchain_info(); + }; +}; +``` + + + + +```rust +use ic_cdk_bitcoin_canister::{get_blockchain_info, BlockchainInfo, Network}; + +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, + } +} + +#[ic_cdk::update] +async fn get_blockchain_info_handler() -> BlockchainInfo { + get_blockchain_info(get_network()) + .await + .expect("Failed to get blockchain info") +} +``` + +Add to `Cargo.toml` alongside `ic-cdk`: + +```toml +ic-cdk-bitcoin-canister = "0.2" +``` + + + + +Full implementation: [basic_bitcoin example (Rust)](https://github.com/dfinity/examples/tree/master/rust/basic_bitcoin), `src/service/get_blockchain_info.rs`. + ### Developer workflow Building a full Bitcoin transaction flow involves these steps: 1. **Generate a Bitcoin address** from a threshold ECDSA or Schnorr public key 2. **Read UTXOs** for the address using `bitcoin_get_utxos` -3. **Select UTXOs and calculate the fee** — see below +3. **Select UTXOs and calculate the fee** (see [UTXO selection](#utxo-selection) below) 4. **Build the unsigned transaction** from the selected UTXOs, recipient output, and change output 5. **Sign each input** using `sign_with_ecdsa` or `sign_with_schnorr` 6. **Submit the transaction** using `bitcoin_send_transaction` -#### UTXO selection +Address generation, transaction construction, signing, and submission together exceed 30 lines per language. See these working examples for the full flow: + +- [basic_bitcoin (Motoko)](https://github.com/dfinity/examples/tree/master/motoko/basic_bitcoin): full send/receive with ECDSA and Schnorr +- [basic_bitcoin (Rust)](https://github.com/dfinity/examples/tree/master/rust/basic_bitcoin): full send/receive with ECDSA and Schnorr +- [threshold-ecdsa (Motoko)](https://github.com/dfinity/examples/tree/master/motoko/threshold-ecdsa): ECDSA signing + +### UTXO selection Transaction fee depends on transaction size in bytes, which depends on the number of inputs, which depends on which UTXOs are selected. Because fee and input count are mutually dependent, the calculation is iterative: start with fee=0, select UTXOs to cover `amount + 0`, estimate the signed transaction size with a mock signer, recalculate fee, repeat until the fee stabilises. @@ -872,7 +893,7 @@ Two selection strategies cover the common cases: **Greedy (standard payments):** accumulate UTXOs oldest-first until the total covers `amount + fee`. Consolidates old UTXOs and reduces wallet fragmentation over time. -**Single UTXO (Ordinals, Runes, BRC-20):** find the first UTXO that alone covers `amount + fee`. Required when the asset is inscribed on a specific satoshi — spending multiple UTXOs risks accidentally burning the inscription. +**Single UTXO (Ordinals, Runes, BRC-20):** find the first UTXO that alone covers `amount + fee`. Required when the asset is inscribed on a specific satoshi; spending multiple UTXOs risks accidentally burning the inscription. @@ -954,12 +975,6 @@ fn select_one_utxo<'a>( -Address generation, transaction construction, signing, and submission together exceed 30 lines per language. See these working examples for the full flow: - -- [basic_bitcoin (Motoko)](https://github.com/dfinity/examples/tree/master/motoko/basic_bitcoin): full send/receive with ECDSA and Schnorr -- [basic_bitcoin (Rust)](https://github.com/dfinity/examples/tree/master/rust/basic_bitcoin): full send/receive with ECDSA and Schnorr -- [threshold-ecdsa (Motoko)](https://github.com/dfinity/examples/tree/master/motoko/threshold-ecdsa): ECDSA signing - ### Common mistakes - **Not paginating `bitcoin_get_utxos`.** The response includes a `next_page` field. For addresses with many transactions a single call may not return all UTXOs. If `next_page` is non-null, call again with `filter = ?#page(next_page)` (Motoko) or `UtxosFilter::Page(next_page)` (Rust) until `next_page` is null.