Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 148 additions & 133 deletions docs/guides/chain-fusion/bitcoin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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="<paste-recipient-principal>"
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
Expand Down Expand Up @@ -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 |

<Tabs syncKey="lang">
<TabItem label="Motoko">

```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;
};

// <system> capability is required to access Runtime.envVar (reads environment variables at runtime)
private func getNetwork<system>() : 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();
};
};
```

</TabItem>
<TabItem label="Rust">

```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"
```

</TabItem>
</Tabs>

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

Expand Down Expand Up @@ -763,11 +668,11 @@ async fn get_utxos(address: String) -> GetUtxosResponse {
</TabItem>
</Tabs>

`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.

<Tabs syncKey="lang">
<TabItem label="Motoko">
Expand Down Expand Up @@ -853,26 +758,142 @@ async fn get_fee_per_byte(network: Network) -> MillisatoshiPerByte {
</TabItem>
</Tabs>

### 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 |

<Tabs syncKey="lang">
<TabItem label="Motoko">

```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;
};

// <system> capability is required to access Runtime.envVar (reads environment variables at runtime)
private func getNetwork<system>() : 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();
};
};
```

</TabItem>
<TabItem label="Rust">

```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"
```

</TabItem>
</Tabs>

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.

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.

<Tabs syncKey="lang">
<TabItem label="Motoko">
Expand Down Expand Up @@ -954,12 +975,6 @@ fn select_one_utxo<'a>(
</TabItem>
</Tabs>

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.
Expand Down
Loading