Install
$ agentstack add skill-dfinity-icskills-ckbtc ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
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:
mopspackage 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_addresson 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_balanceon 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_approveon the ckBTC ledger to grant the minter canister an allowance to spend from your account. - Call
retrieve_btc_with_approvalon 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_transferdeducts 10 satoshis beyond the amount. If the user has exactly 1000 satoshis and you transfer 1000, it fails withInsufficientFunds. Transferbalance - 10instead.
- Not calling
update_balanceafter a BTC deposit. Sending BTC to the deposit address does nothing until you callupdate_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 legacyAccountIdentifier(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_btcwith amount below the minimum. The minter has a minimum withdrawal amount (currently 50,000 satoshis / 0.0005 BTC). Below this, you getAmountTooLow.
- Not checking the
retrieve_btcresponse for errors. The response is a variant:Okcontains{ block_index },Errcontains specific errors likeMalformedAddress,InsufficientFunds,TemporarilyUnavailable. Always match both arms.
- Forgetting
owneringet_btc_addressargs. If you omitowner, Candid sub-typing assigns null, and the minter returns the deposit address of the caller (the canister) instead of the user.
Implementation
Motoko
mops.toml
[package]
name = "ckbtc-app"
version = "0.1.0"
[dependencies]
core = "2.0.0"
icrc2-types = "1.1.0"
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@v4.1.0"
configuration:
main: src/backend/main.mo
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 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(32, func(i : Nat) : Nat8 {
if (i == 0) { Nat8.fromNat(size) }
else if (i ,
subaccount: Option>,
}
#[derive(CandidType, Deserialize, Debug)]
struct UpdateBalanceArgs {
owner: Option,
subaccount: Option>,
}
#[derive(CandidType, Deserialize, Debug)]
struct RetrieveBtcWithApprovalArgs {
address: String,
amount: u64,
from_subaccount: Option>,
}
#[derive(CandidType, Deserialize, Debug)]
struct RetrieveBtcOk {
block_index: u64,
}
#[derive(CandidType, Deserialize, Debug)]
enum RetrieveBtcError {
MalformedAddress(String),
AlreadyProcessing,
AmountTooLow(u64),
InsufficientFunds { balance: u64 },
InsufficientAllowance { allowance: u64 },
TemporarilyUnavailable(String),
GenericError { error_code: u64, error_message: String },
}
#[derive(CandidType, Deserialize, Debug)]
struct Utxo {
outpoint: OutPoint,
value: u64,
height: u32,
}
#[derive(CandidType, Deserialize, Debug)]
struct OutPoint {
txid: Vec,
vout: u32,
}
#[derive(CandidType, Deserialize, Debug)]
struct PendingUtxo {
outpoint: OutPoint,
value: u64,
confirmations: u32,
}
#[derive(CandidType, Deserialize, Debug)]
enum UtxoStatus {
ValueTooSmall(Utxo),
Tainted(Utxo),
Checked(Utxo),
Minted {
block_index: u64,
minted_amount: u64,
utxo: Utxo,
},
}
#[derive(CandidType, Deserialize, Debug)]
enum UpdateBalanceError {
NoNewUtxos {
required_confirmations: u32,
pending_utxos: Option>,
current_confirmations: Option,
},
AlreadyProcessing,
TemporarilyUnavailable(String),
GenericError { error_code: u64, error_message: String },
}
type UpdateBalanceResult = Result, UpdateBalanceError>;
type RetrieveBtcResult = Result;
// -- Subaccount derivation --
// Derive a 32-byte subaccount from a principal for per-user deposit addresses.
fn principal_to_subaccount(principal: &Principal) -> [u8; 32] {
let mut subaccount = [0u8; 32];
let principal_bytes = principal.as_slice();
subaccount[0] = principal_bytes.len() as u8;
subaccount[1..1 + principal_bytes.len()].copy_from_slice(principal_bytes);
subaccount
}
fn ledger_id() -> Principal {
Principal::from_text(CKBTC_LEDGER).unwrap()
}
fn minter_id() -> Principal {
Principal::from_text(CKBTC_MINTER).unwrap()
}
// -- Deposit: Get user's BTC deposit address --
#[update]
async fn get_deposit_address() -> String {
let caller = ic_cdk::api::msg_caller();
assert_ne!(caller, Principal::anonymous(), "Authentication required");
let subaccount = principal_to_subaccount(&caller);
let args = GetBtcAddressArgs {
owner: Some(ic_cdk::api::canister_self()),
subaccount: Some(subaccount.to_vec()),
};
let (address,): (String,) = Call::unbounded_wait(minter_id(), "get_btc_address")
.with_arg(args)
.await
.expect("Failed to get BTC address")
.candid_tuple()
.expect("Failed to decode response");
address
}
// -- Deposit: Check for new BTC and mint ckBTC --
#[update]
async fn update_balance() -> UpdateBalanceResult {
let caller = ic_cdk::api::msg_caller();
assert_ne!(caller, Principal::anonymous(), "Authentication required");
let subaccount = principal_to_subaccount(&caller);
let args = UpdateBalanceArgs {
owner: Some(ic_cdk::api::canister_self()),
subaccount: Some(subaccount.to_vec()),
};
let (result,): (UpdateBalanceResult,) = Call::unbounded_wait(minter_id(), "update_balance")
.with_arg(args)
.await
.expect("Failed to call update_balance")
.candid_tuple()
.expect("Failed to decode response");
result
}
// -- Check user's ckBTC balance --
#[update]
async fn get_balance() -> Nat {
let caller = ic_cdk::api::msg_caller();
assert_ne!(caller, Principal::anonymous(), "Authentication required");
let subaccount = principal_to_subaccount(&caller);
let account = Account {
owner: ic_cdk::api::canister_self(),
subaccount: Some(subaccount),
};
let (balance,): (Nat,) = Call::unbounded_wait(ledger_id(), "icrc1_balance_of")
.with_arg(account)
.await
.expect("Failed to get balance")
.candid_tuple()
.expect("Failed to decode response");
balance
}
// -- Transfer ckBTC to another user --
#[update]
async fn transfer(to: Principal, amount: Nat) -> Result {
let caller = ic_cdk::api::msg_caller();
assert_ne!(caller, Principal::anonymous(), "Authentication required");
let from_subaccount = principal_to_subaccount(&caller);
let args = TransferArg {
from_subaccount: Some(from_subaccount),
to: Account {
owner: to,
subaccount: None,
},
amount,
fee: Some(Nat::from(10u64)), // 10 satoshis
memo: None,
created_at_time: None,
};
let (result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc1_transfer")
.with_arg(args)
.await
.expect("Failed to call icrc1_transfer")
.candid_tuple()
.expect("Failed to decode response");
result
}
// -- Withdraw: Convert ckBTC back to BTC --
#[update]
async fn withdraw(btc_address: String, amount: u64) -> RetrieveBtcResult {
let caller = ic_cdk::api::msg_caller();
assert_ne!(caller, Principal::anonymous(), "Authentication required");
// Step 1: Approve the minter to spend ckBTC from the user's subaccount
let from_subaccount = principal_to_subaccount(&caller);
let approve_args = ApproveArgs {
from_subaccount: Some(from_subaccount),
spender: Account {
owner: minter_id(),
subaccount: None,
},
amount: Nat::from(amount) + Nat::from(10u64), // amount + fee for the minter's burn
expected_allowance: None,
expires_at: None,
fee: Some(Nat::from(10u64)),
memo: None,
created_at_time: None,
};
let (approve_result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc2_approve")
.with_arg(approve_args)
.await
.expect("Failed to call icrc2_approve")
.candid_tuple()
.expect("Failed to decode response");
if let Err(e) = approve_result {
return Err(RetrieveBtcError::GenericError {
error_code: 0,
error_message: format!("Approve for minter failed: {:?}", e),
});
}
// Step 2: Call retrieve_btc_with_approval on the minter
let args = RetrieveBtcWithApprovalArgs {
address: btc_address,
amount,
from_subaccount: Some(from_subaccount.to_vec()),
};
let (result,): (RetrieveBtcResult,) = Call::unbounded_wait(minter_id(), "retrieve_btc_with_approval")
.with_arg(args)
.await
.expect("Failed to call retrieve_btc_with_approval")
.candid_tuple()
.expect("Failed to decode response");
result
}
// -- Export Candid interface --
ic_cdk::export_candid!();
Deploy & Test
Local Development
There is no local ckBTC minter. For local testing, mock the minter interface or test against mainnet/testnet.
Deploy to Mainnet
# Deploy your backend canister
icp deploy backend -e ic
# Your canister calls the mainnet ckBTC canisters directly by principal
Using icp to Interact with ckBTC Directly
# Check ckBTC balance for an account
icp cani
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [dfinity](https://github.com/dfinity)
- **Source:** [dfinity/icskills](https://github.com/dfinity/icskills)
- **License:** Apache-2.0
- **Homepage:** https://skills.internetcomputer.org
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.