| name | ckbtc |
| description | Accept, send, and manage ckBTC (chain-key Bitcoin). Covers BTC deposit flow via minter, ckBTC transfers, withdrawal to BTC, subaccount derivation, and UTXO management. Use when integrating Bitcoin, ckBTC, BTC deposits, or BTC withdrawals in a canister. Do NOT use for plain token transfers without BTC minting/withdrawal — use icrc-ledger instead. |
| license | Apache-2.0 |
| compatibility | icp-cli >= 0.2.2 |
| metadata | {"title":"ckBTC (chain-key Bitcoin)","category":"DeFi"} |
Chain-Key Bitcoin (ckBTC) Integration
What This Is
ckBTC is a 1:1 BTC-backed token native to the Internet Computer. No bridges, no wrapping, no third-party custodians. The ckBTC minter canister holds real BTC and mints/burns ckBTC tokens. Transfers settle in 1-2 seconds with a 10 satoshi fee (versus minutes and thousands of satoshis on Bitcoin L1).
Prerequisites
- For Motoko:
mops package manager, core = "2.0.0" in mops.toml
- For Rust:
ic-cdk, icrc-ledger-types, candid, serde
Canister IDs
Bitcoin Mainnet
| Canister | ID |
|---|
| ckBTC Ledger | mxzaz-hqaaa-aaaar-qaada-cai |
| ckBTC Minter | mqygn-kiaaa-aaaar-qaadq-cai |
| ckBTC Index | n5wcd-faaaa-aaaar-qaaea-cai |
| ckBTC Checker | oltsj-fqaaa-aaaar-qal5q-cai |
Bitcoin Testnet4
| Canister | ID |
|---|
| ckBTC Ledger | mc6ru-gyaaa-aaaar-qaaaq-cai |
| ckBTC Minter | ml52i-qqaaa-aaaar-qaaba-cai |
| ckBTC Index | mm444-5iaaa-aaaar-qaabq-cai |
How It Works
Deposit Flow (BTC -> ckBTC)
- Call
get_btc_address on the minter with the user's principal + subaccount. This returns a unique Bitcoin address controlled by the minter.
- User sends BTC to that address using any Bitcoin wallet.
- Wait for Bitcoin confirmations (the minter requires confirmations before minting).
- Call
update_balance on the minter with the same principal + subaccount. The minter checks for new UTXOs and mints equivalent ckBTC to the user's ICRC-1 account.
Transfer Flow (ckBTC -> ckBTC)
Call icrc1_transfer on the ckBTC ledger. Fee is 10 satoshis. Settles in 1-2 seconds.
Withdrawal Flow (ckBTC -> BTC)
- Call
icrc2_approve on the ckBTC ledger to grant the minter canister an allowance to spend from your account.
- Call
retrieve_btc_with_approval on the minter with { address, amount, from_subaccount: null }.
- The minter uses the approval to burn the ckBTC and submits a Bitcoin transaction.
- The BTC arrives at the destination address after Bitcoin confirmations.
Subaccount Generation
Each user gets a unique deposit address derived from their principal + an optional 32-byte subaccount. To give each user a distinct deposit address within your canister, derive subaccounts from a user-specific identifier (their principal or a sequential ID).
Mistakes That Break Your Build
-
Using the wrong minter canister ID. The minter ID is mqygn-kiaaa-aaaar-qaadq-cai. Do not confuse it with the ledger (mxzaz-...) or index (n5wcd-...).
-
Forgetting the 10 satoshi transfer fee. Every icrc1_transfer deducts 10 satoshis beyond the amount. If the user has exactly 1000 satoshis and you transfer 1000, it fails with InsufficientFunds. Transfer balance - 10 instead.
-
Not calling update_balance after a BTC deposit. Sending BTC to the deposit address does nothing until you call update_balance. The minter does not auto-detect deposits. Your app must call this.
-
Using Account Identifier 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. Passing a subaccount shorter or longer than 32 bytes causes a trap. Pad with leading zeros if deriving from a shorter value.
-
Calling retrieve_btc with amount below the minimum. The minter has a minimum withdrawal amount (currently 50,000 satoshis / 0.0005 BTC). Below this, you get AmountTooLow.
-
Not checking the retrieve_btc response for errors. The response is a variant: Ok contains { block_index }, Err contains specific errors like MalformedAddress, InsufficientFunds, TemporarilyUnavailable. Always match both arms.
-
Forgetting owner in get_btc_address args. If you omit owner, Candid sub-typing assigns null, and the minter returns the deposit address of the caller (the canister) instead of the user.
-
Leaving created_at_time null on icrc1_transfer / icrc2_approve. The ICRC-1 spec says a ledger SHOULD NOT deduplicate a transaction whose created_at_time is unset, so a retried transfer executes a second time and moves real BTC-backed value twice. Set it, and retry with the same timestamp — the ledger only matches duplicates when every argument is identical, so a fresh Time.now() on retry defeats dedup. A retry that was already applied returns #Err(#Duplicate { duplicate_of }), which is a success, not a failure.
Implementation
Motoko
mops.toml
[package]
name = "ckbtc-app"
version = "0.1.0"
[dependencies]
core = "2.0.0"
icrc2-types = "1.1.0"
[canisters.backend]
main = "src/backend/main.mo"
The @dfinity/motoko@v5+ recipe compiles via mops build <canister-name>, so main lives here — not in recipe.configuration in icp.yaml. The [canisters.<name>] key must exactly match the canister name in icp.yaml.
icp.yaml
Your backend canister calls the ckBTC ledger and minter by principal directly — no local ckBTC canister deployment needed.
canisters:
- name: backend
recipe:
type: "@dfinity/motoko@v5.0.0"
src/backend/main.mo
import Principal "mo:core/Principal";
import Blob "mo:core/Blob";
import Nat "mo:core/Nat";
import Nat8 "mo:core/Nat8";
import Nat64 "mo:core/Nat64";
import Int "mo:core/Int";
import Time "mo:core/Time";
import Array "mo:core/Array";
import Result "mo:core/Result";
import Error "mo:core/Error";
import Runtime "mo:core/Runtime";
persistent actor Self {
// -- Types --
type Account = {
owner : Principal;
subaccount : ?Blob;
};
type TransferArgs = {
from_subaccount : ?Blob;
to : Account;
amount : Nat;
fee : ?Nat;
memo : ?Blob;
created_at_time : ?Nat64;
};
type TransferResult = {
#Ok : Nat; // block index
#Err : TransferError;
};
type TransferError = {
#BadFee : { expected_fee : Nat };
#BadBurn : { min_burn_amount : Nat };
#InsufficientFunds : { balance : Nat };
#TooOld;
#CreatedInFuture : { ledger_time : Nat64 };
#Duplicate : { duplicate_of : Nat };
#TemporarilyUnavailable;
#GenericError : { error_code : Nat; message : Text };
};
type UpdateBalanceResult = {
#Ok : [UtxoStatus];
#Err : UpdateBalanceError;
};
type UtxoStatus = {
#ValueTooSmall : Utxo;
#Tainted : Utxo;
#Checked : Utxo;
#Minted : { block_index : Nat64; minted_amount : Nat64; utxo : Utxo };
};
type Utxo = {
outpoint : { txid : Blob; vout : Nat32 };
value : Nat64;
height : Nat32;
};
type UpdateBalanceError = {
#NoNewUtxos : {
required_confirmations : Nat32;
pending_utxos : ?[PendingUtxo];
current_confirmations : ?Nat32;
};
#AlreadyProcessing;
#TemporarilyUnavailable : Text;
#GenericError : { error_code : Nat64; error_message : Text };
};
type PendingUtxo = {
outpoint : { txid : Blob; vout : Nat32 };
value : Nat64;
confirmations : Nat32;
};
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;
};
type RetrieveBtcError = {
#MalformedAddress : Text;
#AlreadyProcessing;
#AmountTooLow : Nat64;
#InsufficientFunds : { balance : Nat64 };
#InsufficientAllowance : { allowance : Nat64 };
#TemporarilyUnavailable : Text;
#GenericError : { error_code : Nat64; error_message : Text };
};
// -- Remote canister references (mainnet) --
transient let ckbtcLedger : actor {
icrc1_transfer : shared (TransferArgs) -> async TransferResult;
icrc1_balance_of : shared query (Account) -> async Nat;
icrc1_fee : shared query () -> async Nat;
icrc2_approve : shared (ApproveArgs) -> async { #Ok : Nat; #Err : ApproveError };
} = actor "mxzaz-hqaaa-aaaar-qaada-cai";
transient let ckbtcMinter : actor {
get_btc_address : shared ({
owner : ?Principal;
subaccount : ?Blob;
}) -> async Text;
update_balance : shared ({
owner : ?Principal;
subaccount : ?Blob;
}) -> async UpdateBalanceResult;
retrieve_btc_with_approval : shared (RetrieveBtcWithApprovalArgs) -> async RetrieveBtcResult;
} = actor "mqygn-kiaaa-aaaar-qaadq-cai";
// -- Subaccount derivation --
// Derive a 32-byte subaccount from a principal for per-user deposit addresses.
func principalToSubaccount(p : Principal) : Blob {
let bytes = Blob.toArray(Principal.toBlob(p));
let size = bytes.size();
// First byte is length, remaining padded to 32 bytes
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)
};
// -- Deposit: Get user's BTC deposit address --
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;
})
};
// -- Deposit: Check for new BTC and mint ckBTC --
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;
})
};
// -- Check user's ckBTC balance --
public shared ({ caller }) func getBalance() : async Nat {
if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") };
let subaccount = principalToSubaccount(caller);
await ckbtcLedger.icrc1_balance_of({
owner = Principal.fromActor(Self);
subaccount = ?subaccount;
})
};
// -- Transfer ckBTC to another user --
public shared ({ caller }) func transfer(to : Principal, amount : Nat) : async TransferResult {
if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") };
let fromSubaccount = principalToSubaccount(caller);
let now = Nat64.fromNat(Int.abs(Time.now()));
await ckbtcLedger.icrc1_transfer({
from_subaccount = ?fromSubaccount;
to = { owner = to; subaccount = null };
amount = amount;
fee = ?10; // 10 satoshis
memo = null;
created_at_time = ?now; // required for dedup — see Pitfall 9
})
};
// -- Withdraw: Convert ckBTC back to BTC --
public shared ({ caller }) func withdraw(btcAddress : Text, amount : Nat64) : async RetrieveBtcResult {
if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") };
// Step 1: Approve the minter to spend ckBTC from the user's subaccount
let fromSubaccount = principalToSubaccount(caller);
let now = Nat64.fromNat(Int.abs(Time.now()));
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; // amount + fee for the minter's burn
expected_allowance = null;
expires_at = null;
fee = ?10;
memo = null;
created_at_time = ?now; // required for dedup — see Pitfall 9
});
switch (approveResult) {
case (#Err(e)) { return #Err(#GenericError({ error_code = 0; error_message = "Approve for minter failed" })) };
case (#Ok(_)) {};
};
// Step 2: Call retrieve_btc_with_approval on the minter
await ckbtcMinter.retrieve_btc_with_approval({
address = btcAddress;
amount = amount;
from_subaccount = ?fromSubaccount;
})
};
};
Rust
Cargo.toml
[package]
name = "ckbtc_backend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.19"
ic-cdk-timers = "1.0"
candid = "0.10"
serde = { version = "1", features = ["derive"] }