For AI agents: Documentation index at /llms.txt

Skip to content

Bitcoin Integration

ICP provides a protocol-level integration with the Bitcoin network. Canisters can hold BTC, generate Bitcoin addresses, build transactions, sign them with threshold ECDSA or Schnorr signatures, and submit them to the Bitcoin network: all without bridges or oracles.

There are two approaches to working with Bitcoin on ICP:

  • ckBTC (chain-key Bitcoin): a 1:1 BTC-backed token native to ICP. Transfers settle in 1-2 seconds with a 10 satoshi fee. Best for most applications that need to accept, hold, or transfer Bitcoin value.
  • Direct Bitcoin API: call the Bitcoin canister to read UTXOs, get balances, and submit raw Bitcoin transactions. Best for advanced use cases that need full control over Bitcoin transactions (custom scripts, Ordinals, Runes, BRC-20).

This guide covers both approaches.

ckBTC integration

ckBTC is the recommended path for most developers. The ckBTC minter canister holds real BTC and mints/burns ckBTC tokens. Your canister interacts with the minter and ledger canisters using standard ICRC-1/ICRC-2 interfaces.

Canister IDs

CanisterMainnetTestnet4
ckBTC Ledgermxzaz-hqaaa-aaaar-qaada-caimc6ru-gyaaa-aaaar-qaaaq-cai
ckBTC Mintermqygn-kiaaa-aaaar-qaadq-caiml52i-qqaaa-aaaar-qaaba-cai
ckBTC Indexn5wcd-faaaa-aaaar-qaaea-caimm444-5iaaa-aaaar-qaabq-cai
ckBTC Checkeroltsj-fqaaa-aaaar-qal5q-cai-

Deposit flow (BTC to ckBTC)

PlantUML diagram
  1. Call get_btc_address on the minter with the user’s principal and subaccount. This returns a unique Bitcoin address controlled by the minter via threshold ECDSA.
  2. Send BTC to that address from any Bitcoin wallet.
  3. Wait for 4 Bitcoin confirmations (mainnet). The minter will not process UTXOs until the required number of confirmations is reached.
  4. Call update_balance on the minter. The minter checks for new UTXOs, runs a KYT (Know-Your-Transaction) compliance check via the Bitcoin Checker canister, and mints ckBTC to the user’s ICRC-1 account. A KYT fee of 100 satoshis is deducted per UTXO. UTXOs that fail the KYT check are quarantined and not minted.
import Principal "mo:core/Principal";
import Blob "mo:core/Blob";
import Nat8 "mo:core/Nat8";
import Array "mo:core/Array";
import Runtime "mo:core/Runtime";
persistent actor Self {
type Account = { owner : Principal; subaccount : ?Blob };
type UpdateBalanceResult = { #Ok : [UtxoStatus]; #Err : UpdateBalanceError };
// See the full example for UtxoStatus and UpdateBalanceError definitions
transient let ckbtcMinter : actor {
get_btc_address : shared ({ owner : ?Principal; subaccount : ?Blob }) -> async Text;
update_balance : shared ({ owner : ?Principal; subaccount : ?Blob }) -> async UpdateBalanceResult;
} = actor "mqygn-kiaaa-aaaar-qaadq-cai";
func principalToSubaccount(p : Principal) : Blob {
let bytes = Blob.toArray(Principal.toBlob(p));
let size = bytes.size();
let sub = Array.tabulate<Nat8>(32, func(i : Nat) : Nat8 {
if (i == 0) { Nat8.fromNat(size) }
else if (i <= size) { bytes[i - 1] }
else { 0 }
});
Blob.fromArray(sub)
};
public shared ({ caller }) func getDepositAddress() : async Text {
if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") };
let subaccount = principalToSubaccount(caller);
await ckbtcMinter.get_btc_address({
owner = ?Principal.fromActor(Self);
subaccount = ?subaccount;
})
};
public shared ({ caller }) func updateBalance() : async UpdateBalanceResult {
if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") };
let subaccount = principalToSubaccount(caller);
await ckbtcMinter.update_balance({
owner = ?Principal.fromActor(Self);
subaccount = ?subaccount;
})
};
};

Transfer ckBTC

Call icrc1_transfer on the ckBTC ledger. The fee is 10 satoshis and transfers settle in 1-2 seconds.

// Inside your persistent actor:
type TransferArgs = {
from_subaccount : ?Blob;
to : Account;
amount : Nat;
fee : ?Nat;
memo : ?Blob;
created_at_time : ?Nat64;
};
type TransferResult = { #Ok : Nat; #Err : TransferError };
// See the full example for TransferError definition
transient let ckbtcLedger : actor {
icrc1_transfer : shared (TransferArgs) -> async TransferResult;
} = actor "mxzaz-hqaaa-aaaar-qaada-cai";
public shared ({ caller }) func transfer(to : Principal, amount : Nat) : async TransferResult {
if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") };
let fromSubaccount = principalToSubaccount(caller);
await ckbtcLedger.icrc1_transfer({
from_subaccount = ?fromSubaccount;
to = { owner = to; subaccount = null };
amount = amount;
fee = ?10;
memo = null;
created_at_time = null;
})
};

Withdraw (ckBTC to BTC)

PlantUML diagram

Withdrawal is a two-step process: approve the minter to spend your ckBTC, then call retrieve_btc_with_approval. Before burning ckBTC, the minter runs a KYT check on the destination Bitcoin address. The Bitcoin transaction is submitted asynchronously — the minter batches pending requests to optimize miner fees. Track status with retrieve_btc_status_v2(block_index). The minimum withdrawal is 50,000 satoshis (0.0005 BTC).

// Inside your persistent actor:
type ApproveArgs = {
from_subaccount : ?Blob;
spender : Account;
amount : Nat;
expected_allowance : ?Nat;
expires_at : ?Nat64;
fee : ?Nat;
memo : ?Blob;
created_at_time : ?Nat64;
};
type ApproveError = {
#BadFee : { expected_fee : Nat };
#InsufficientFunds : { balance : Nat };
#AllowanceChanged : { current_allowance : Nat };
#Expired : { ledger_time : Nat64 };
#TooOld;
#CreatedInFuture : { ledger_time : Nat64 };
#Duplicate : { duplicate_of : Nat };
#TemporarilyUnavailable;
#GenericError : { error_code : Nat; message : Text };
};
type RetrieveBtcWithApprovalArgs = {
address : Text;
amount : Nat64;
from_subaccount : ?Blob;
};
type RetrieveBtcResult = {
#Ok : { block_index : Nat64 };
#Err : RetrieveBtcError;
};
// See the full example for RetrieveBtcError definition
public shared ({ caller }) func withdraw(btcAddress : Text, amount : Nat64) : async RetrieveBtcResult {
if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") };
let fromSubaccount = principalToSubaccount(caller);
let approveResult = await ckbtcLedger.icrc2_approve({
from_subaccount = ?fromSubaccount;
spender = {
owner = Principal.fromText("mqygn-kiaaa-aaaar-qaadq-cai");
subaccount = null;
};
amount = Nat64.toNat(amount) + 10;
expected_allowance = null;
expires_at = null;
fee = ?10;
memo = null;
created_at_time = null;
});
switch (approveResult) {
case (#Err(_)) {
return #Err(#GenericError({ error_code = 0; error_message = "Approve failed" }))
};
case (#Ok(_)) {};
};
await ckbtcMinter.retrieve_btc_with_approval({
address = btcAddress;
amount = amount;
from_subaccount = ?fromSubaccount;
})
};

Deposit, mint, and transfer (icp-cli)

This walkthrough covers the full ckBTC deposit flow: getting a deposit address, checking the confirmation requirement, minting ckBTC, and transferring to another principal.

First, export your principal from your active identity (every command below reuses it):

Terminal window
export MY_PRINCIPAL=$(icp identity principal)

Step 1: Get a deposit address

Terminal window
icp canister call mqygn-kiaaa-aaaar-qaadq-cai get_btc_address \
"(record { owner = opt principal \"$MY_PRINCIPAL\"; subaccount = null })" \
-n ic

Send BTC to the returned address from any Bitcoin wallet.

Step 2: Check the confirmation requirement

The minter does not mint ckBTC until the depositing Bitcoin transaction reaches a minimum number of confirmations. Query the current threshold before waiting:

Terminal window
icp canister call mqygn-kiaaa-aaaar-qaadq-cai get_minter_info '()' -n ic

The response includes min_confirmations (how many Bitcoin confirmations are required before minting, currently 4 on mainnet), kyt_fee (the know-your-transaction check fee charged per deposit, in satoshis), and retrieve_btc_min_amount (the minimum withdrawal amount, currently 50,000 satoshis).

Step 3: Mint ckBTC

Once the Bitcoin transaction has the required confirmations, call update_balance to trigger minting:

Terminal window
icp canister call mqygn-kiaaa-aaaar-qaadq-cai update_balance \
"(record { owner = opt principal \"$MY_PRINCIPAL\"; subaccount = null })" \
-n ic

A Minted record in the response confirms that ckBTC was credited to your account. If the response is Err(NoNewUtxos { current_confirmations = opt N }), the transaction exists but has not yet reached the required count.

Step 4: Check your balance

Terminal window
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \
"(record { owner = principal \"$MY_PRINCIPAL\"; subaccount = null })" \
-n ic

Step 5: Transfer ckBTC

Set the recipient principal: export RECIPIENT="<paste-recipient-principal>". The 10 satoshi fee is charged in addition to the amount.

Terminal window
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer \
"(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

created_at_time = null skips deduplication: if you run this command twice, both transfers execute. In production canister code, set this field to the current nanosecond timestamp so that retried calls are rejected as duplicates rather than sending twice. See Transaction deduplication for details.

Common mistakes

  • Not calling update_balance after a BTC deposit. The minter does not auto-detect deposits. Your application must call update_balance to trigger minting.
  • Forgetting the 10 satoshi transfer fee. If a user has exactly 1000 satoshis and you transfer 1000, it fails with InsufficientFunds. Transfer balance - 10 instead.
  • Using AccountIdentifier instead of ICRC-1 Account. ckBTC uses the ICRC-1 standard: { owner: Principal, subaccount: ?Blob }. Do not use the legacy AccountIdentifier (hex string) from the ICP ledger.
  • Subaccount must be exactly 32 bytes or null. A subaccount shorter or longer than 32 bytes causes a trap.
  • Minimum withdrawal is 50,000 satoshis. Amounts below this return AmountTooLow.
  • Omitting owner in get_btc_address. Without owner, the minter returns the deposit address of the calling canister instead of the intended user.

For a complete working example with all type definitions and error handling, see the ckBTC skill or the full code in the basic_bitcoin Motoko example and basic_bitcoin Rust example.

Direct Bitcoin API

For use cases that require full control over Bitcoin transactions (custom scripts, Ordinals, Runes, BRC-20), you can call the Bitcoin canister directly. This involves generating addresses with threshold ECDSA or Schnorr signatures, building raw transactions, and submitting them to the Bitcoin network.

Bitcoin API canister IDs

IC networkBitcoin networkCanister ID
Local (PocketIC)regtestg4xu7-jiaaa-aaaan-aaaaq-cai
IC mainnettestnet4g4xu7-jiaaa-aaaan-aaaaq-cai
IC mainnetmainnetghsi2-tqaaa-aaaan-aaaca-cai

Available endpoints

The Bitcoin canister exposes these methods:

  • bitcoin_get_balance: returns the balance of a Bitcoin address in satoshis
  • bitcoin_get_utxos: returns unspent transaction outputs for an address
  • bitcoin_get_current_fee_percentiles: returns fee percentiles from recent transactions
  • bitcoin_get_block_headers: returns raw block headers for a height range
  • bitcoin_send_transaction: submits a signed transaction to the Bitcoin network
  • get_blockchain_info: returns chain state (tip height, block hash, timestamp, difficulty, UTXO count)

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). The ic-cdk-bitcoin-canister crate handles them automatically in Rust; in Motoko attach cycles explicitly with (with cycles = amount).

Read Bitcoin balance

import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
persistent actor Backend {
public type Satoshi = Nat64;
public type BitcoinAddress = Text;
public type Network = { #mainnet; #testnet; #regtest };
type BitcoinCanister = actor {
bitcoin_get_balance : shared {
address : BitcoinAddress;
network : Network;
min_confirmations : ?Nat32;
} -> async Satoshi;
};
// <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 getBalanceCost(network : Network) : Nat {
switch (network) {
case (#mainnet) 100_000_000;
case _ 40_000_000;
};
};
public func get_balance(address : BitcoinAddress) : async Satoshi {
let network = getNetwork();
await (with cycles = getBalanceCost(network))
(actor (getBitcoinCanisterId(network)) : BitcoinCanister)
.bitcoin_get_balance({
address;
network;
min_confirmations = null;
});
};
};

Read UTXOs

import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
persistent actor Backend {
public type Satoshi = Nat64;
public type Network = { #mainnet; #testnet; #regtest };
type OutPoint = { txid : Blob; vout : Nat32 };
type Utxo = { outpoint : OutPoint; value : Satoshi; height : Nat32 };
type GetUtxosResponse = {
utxos : [Utxo];
tip_block_hash : Blob;
tip_height : Nat32;
next_page : ?Blob;
};
type BitcoinCanister = actor {
bitcoin_get_utxos : shared {
address : Text;
network : Network;
filter : ?{ #min_confirmations : Nat32; #page : Blob };
} -> async GetUtxosResponse;
};
// <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 getUtxosCost(network : Network) : Nat {
switch (network) {
case (#mainnet) 10_000_000_000;
case _ 4_000_000_000;
};
};
public func get_utxos(address : Text) : async GetUtxosResponse {
let network = getNetwork();
await (with cycles = getUtxosCost(network))
(actor (getBitcoinCanisterId(network)) : BitcoinCanister)
.bitcoin_get_utxos({
address;
network;
filter = 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.

import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
persistent actor Backend {
public type Network = { #mainnet; #testnet; #regtest };
type BitcoinCanister = actor {
bitcoin_get_current_fee_percentiles : shared {
network : Network;
} -> async [Nat64];
};
// <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 getFeePercentilesCost(network : Network) : Nat {
switch (network) {
case (#mainnet) 100_000_000;
case _ 40_000_000;
};
};
public func get_fee_per_byte() : async Nat64 {
let network = getNetwork();
let percentiles = await (with cycles = getFeePercentilesCost(network))
(actor (getBitcoinCanisterId(network)) : BitcoinCanister)
.bitcoin_get_current_fee_percentiles({ network });
if (percentiles.size() == 0) {
2_000 // regtest fallback: 2 sat/vB in millisatoshi
} else {
percentiles[50]
}
};
};

Blockchain info

get_blockchain_info() queries the state of the Bitcoin chain. The response includes:

FieldTypeDescription
heightnat32Current chain tip block height
block_hashblobChain tip block hash
timestampnat32Unix timestamp of the tip block
difficultynatCurrent mining difficulty target
utxos_lengthnat64Total number of UTXOs in the UTXO set
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();
};
};

Full implementation: basic_bitcoin example (Rust), 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 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

Address generation, transaction construction, signing, and submission together exceed 30 lines per language. See these working examples for the full flow:

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.

import Array "mo:core/Array";
import Runtime "mo:core/Runtime";
type Utxo = { outpoint : { txid : Blob; vout : Nat32 }; value : Nat64; height : Nat32 };
// Greedy: accumulate oldest-first until covering amount + fee.
// Use for standard payments.
func selectUtxosGreedy(utxos : [Utxo], amount : Nat64, fee : Nat64) : [Utxo] {
var selected : [Utxo] = [];
var total : Nat64 = 0;
label done for (utxo in Array.reverse(utxos).vals()) {
selected := Array.concat(selected, [utxo]);
total += utxo.value;
if (total >= amount + fee) break done;
};
if (total < amount + fee) Runtime.trap("Insufficient balance");
selected
};
// Single UTXO: find one that alone covers amount + fee.
// Use for Ordinals, Runes, and BRC-20 where the asset is tied to a specific satoshi.
func selectOneUtxo(utxos : [Utxo], amount : Nat64, fee : Nat64) : Utxo {
for (utxo in Array.reverse(utxos).vals()) {
if (utxo.value >= amount + fee) return utxo;
};
Runtime.trap("No single UTXO covers amount + fee")
};

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.
  • Spending unconfirmed or immature UTXOs. Coinbase outputs require 100 confirmations before they can be spent. Non-coinbase UTXOs with zero confirmations carry double-spend risk. Use min_confirmations in the bitcoin_get_utxos filter when building payment flows.
  • Skipping the iterative fee calculation. Transaction size depends on the number of inputs selected. Build the transaction with fee=0, measure the signed size using a mock signer, recalculate the fee, and repeat until stable. Skipping this step produces transactions that underpay (stuck) or overpay.
  • Creating change outputs below the dust threshold. Change less than ~1,000 satoshis is uneconomical and some nodes reject outputs below this level. Either add the dust amount to the miner fee or omit the change output entirely.
  • Concurrent calls spending the same UTXOs. If two update calls fetch the same UTXO set at the same time, both will attempt to spend the same inputs. Only one transaction will be valid on the Bitcoin network; the other will be rejected. Track spent UTXOs in canister state and exclude them from future selections.

Cycle costs

All Bitcoin API calls require cycles attached to the call. In Rust, the ic-cdk-bitcoin-canister crate handles this automatically. In Motoko, attach cycles explicitly with (with cycles = amount).

API callTestnet / RegtestMainnet
bitcoin_get_balance40,000,000100,000,000
bitcoin_get_utxos4,000,000,00010,000,000,000
bitcoin_send_transaction (base)2,000,000,0005,000,000,000
bitcoin_send_transaction (per byte)8,000,00020,000,000
bitcoin_get_current_fee_percentiles40,000,000100,000,000
bitcoin_get_block_headers4,000,000,00010,000,000,000
get_blockchain_info40,000,000100,000,000

Development setup

Quickstart with the bitcoin-starter template

The fastest way to get started is with the bitcoin-starter template:

Terminal window
icp new my-bitcoin-app --subfolder bitcoin-starter
cd my-bitcoin-app

This sets up a project with multi-environment configuration already in place.

Local development with regtest

For local testing, run a Bitcoin regtest node alongside your local ICP network. The icp.yaml configuration connects the two:

canisters:
- backend
networks:
- name: local
mode: managed
bitcoind-addr:
- "127.0.0.1:18444"
environments:
- name: local
network: local
settings:
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"

Start the Bitcoin regtest node (using Docker):

Terminal window
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 \
-fallbackfee=0.00001 -txindex=1

Start the local ICP network and deploy:

Terminal window
icp network start -d
icp deploy

Test with regtest

Create a wallet and mine some blocks:

Terminal window
# Create a regtest wallet
docker exec bitcoind bitcoin-cli -regtest \
-rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \
createwallet "default"
# Get a new address
ADDR=$(docker exec bitcoind bitcoin-cli -regtest \
-rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \
getnewaddress)
# Mine a block (rewards 50 BTC = 5,000,000,000 satoshis)
docker exec bitcoind bitcoin-cli -regtest \
-rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \
generatetoaddress 1 "$ADDR"
# Check balance through your canister
icp canister call backend get_balance "(\"$ADDR\")"

Coinbase rewards require 100 confirmations before they can be spent. If you extend this to send transactions, mine at least 101 blocks so the first block’s reward becomes spendable.

Deploy to testnet and mainnet

Deploy to testnet (Bitcoin testnet4 via the IC mainnet):

Terminal window
icp deploy -e staging

Deploy to production (Bitcoin mainnet via the IC mainnet):

Terminal window
icp deploy -e production

The BITCOIN_NETWORK environment variable controls which Bitcoin network and Bitcoin API canister your code targets, without requiring any code changes.

Cleanup

Terminal window
icp network stop
docker stop bitcoind && docker rm bitcoind

Next steps