ckbtc
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseChain-Key Bitcoin (ckBTC) Integration
Chain-Key Bitcoin (ckBTC) 集成
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).
ckBTC是Internet Computer原生的、与BTC 1:1锚定的代币。无需跨链桥、无需包装、无第三方托管方。ckBTC minter canister持有真实的BTC,并负责铸造/销毁ckBTC代币。转账结算仅需1-2秒,手续费为10聪(对比Bitcoin L1需要数分钟且手续费高达数千聪)。
Prerequisites
前置条件
- For Motoko: package manager,
mopsin mops.tomlcore = "2.0.0" - For Rust: ,
ic-cdk,icrc-ledger-types,candidserde
- 对于Motoko:需要包管理器,且在mops.toml中配置
mopscore = "2.0.0" - 对于Rust:需要依赖、
ic-cdk、icrc-ledger-types、candidserde
Canister IDs
Canister ID
Bitcoin Mainnet
Bitcoin主网
| Canister | ID |
|---|---|
| ckBTC Ledger | |
| ckBTC Minter | |
| ckBTC Index | |
| ckBTC Checker | |
| Canister | ID |
|---|---|
| ckBTC Ledger | |
| ckBTC Minter | |
| ckBTC Index | |
| ckBTC Checker | |
Bitcoin Testnet4
Bitcoin测试网4
| Canister | ID |
|---|---|
| ckBTC Ledger | |
| ckBTC Minter | |
| ckBTC Index | |
| Canister | ID |
|---|---|
| ckBTC Ledger | |
| ckBTC Minter | |
| ckBTC Index | |
How It Works
工作流程
Deposit Flow (BTC -> ckBTC)
存入流程(BTC -> ckBTC)
- Call on the minter with the user's principal + subaccount. This returns a unique Bitcoin address controlled by the minter.
get_btc_address - User sends BTC to that address using any Bitcoin wallet.
- Wait for Bitcoin confirmations (the minter requires confirmations before minting).
- Call 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.
update_balance
- 调用minter的方法,传入用户的principal和子账户。该方法会返回一个由minter控制的唯一Bitcoin地址。
get_btc_address - 用户使用任意Bitcoin钱包向该地址发送BTC。
- 等待Bitcoin网络确认(minter需要确认后才会铸造ckBTC)。
- 再次调用minter的方法,传入相同的principal和子账户。minter会检查新的UTXO,并向用户的ICRC-1账户铸造等额的ckBTC。
update_balance
Transfer Flow (ckBTC -> ckBTC)
转账流程(ckBTC -> ckBTC)
Call on the ckBTC ledger. Fee is 10 satoshis. Settles in 1-2 seconds.
icrc1_transfer调用ckBTC ledger的方法。手续费为10聪,结算时间1-2秒。
icrc1_transferWithdrawal Flow (ckBTC -> BTC)
提取流程(ckBTC -> BTC)
- Call on the ckBTC ledger to grant the minter canister an allowance to spend from your account.
icrc2_approve - Call on the minter with
retrieve_btc_with_approval.{ 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.
- 调用ckBTC ledger的方法,授权minter canister从你的账户中扣款。
icrc2_approve - 调用minter的方法,参数为
retrieve_btc_with_approval。{ address, amount, from_subaccount: null } - minter会使用授权销毁ckBTC,并提交一笔Bitcoin交易。
- 经过Bitcoin网络确认后,BTC会到达目标地址。
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).
每个用户的唯一存入地址由其principal加上可选的32字节子账户派生而来。要在你的canister中为每个用户分配不同的存入地址,可以从用户特定标识(如principal或序列ID)派生子账户。
Mistakes That Break Your Build
常见错误
-
Using the wrong minter canister ID. The minter ID is. Do not confuse it with the ledger (
mqygn-kiaaa-aaaar-qaadq-cai) or index (mxzaz-...).n5wcd-... -
Forgetting the 10 satoshi transfer fee. Everydeducts 10 satoshis beyond the amount. If the user has exactly 1000 satoshis and you transfer 1000, it fails with
icrc1_transfer. TransferInsufficientFundsinstead.balance - 10 -
Not callingafter 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.update_balance -
Using Account Identifier instead of ICRC-1 Account. ckBTC uses the ICRC-1 standard:. Do NOT use the legacy
{ owner: Principal, subaccount: ?Blob }(hex string) from the ICP ledger.AccountIdentifier -
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.
-
Callingwith amount below the minimum. The minter has a minimum withdrawal amount (currently 50,000 satoshis / 0.0005 BTC). Below this, you get
retrieve_btc.AmountTooLow -
Not checking theresponse for errors. The response is a variant:
retrieve_btccontainsOk,{ block_index }contains specific errors likeErr,MalformedAddress,InsufficientFunds. Always match both arms.TemporarilyUnavailable -
Forgettingin
ownerargs. If you omitget_btc_address, Candid sub-typing assigns null, and the minter returns the deposit address of the caller (the canister) instead of the user.owner
-
使用错误的minter canister ID:minter的ID是,不要与ledger(
mqygn-kiaaa-aaaar-qaadq-cai)或index(mxzaz-...)混淆。n5wcd-... -
忘记10聪的转账手续费:每笔都会在转账金额之外扣除10聪手续费。如果用户刚好有1000聪,而你尝试转账1000聪,会因
icrc1_transfer失败。应转账InsufficientFunds聪。余额 - 10 -
BTC存入后未调用:仅向存入地址发送BTC不会有任何效果,必须调用
update_balance方法。minter不会自动检测存入,你的应用必须主动调用该方法。update_balance -
使用Account Identifier而非ICRC-1 Account:ckBTC采用ICRC-1标准,格式为。不要使用ICP ledger中的旧版
{ owner: Principal, subaccount: ?Blob }(十六进制字符串)。AccountIdentifier -
子账户必须是32字节或null:传递长度不足或超过32字节的子账户会导致程序崩溃。如果从更短的值派生,需用前导零填充至32字节。
-
提取金额低于最小值:minter有最低提取金额限制(当前为50,000聪/0.0005 BTC),低于该值会返回错误。
AmountTooLow -
未检查的返回错误:返回结果是变体类型:
retrieve_btc包含Ok,{ block_index }包含Err、MalformedAddress、InsufficientFunds等具体错误。务必处理两种情况。TemporarilyUnavailable -
参数中遗漏
get_btc_address:如果省略owner,Candid子类型会自动赋值为null,minter会返回调用者(canister自身)的存入地址,而非用户的地址。owner
Implementation
实现代码
Motoko
Motoko
mops.toml
mops.toml
toml
[package]
name = "ckbtc-app"
version = "0.1.0"
[dependencies]
core = "2.0.0"
icrc2-types = "1.1.0"toml
[package]
name = "ckbtc-app"
version = "0.1.0"
[dependencies]
core = "2.0.0"
icrc2-types = "1.1.0"icp.yaml
icp.yaml
Your backend canister calls the ckBTC ledger and minter by principal directly — no local ckBTC canister deployment needed.
yaml
canisters:
- name: backend
recipe:
type: "@dfinity/motoko@v4.1.0"
configuration:
main: src/backend/main.mo你的后端canister可直接通过principal调用ckBTC ledger和minter,无需本地部署ckBTC canister。
yaml
canisters:
- name: backend
recipe:
type: "@dfinity/motoko@v4.1.0"
configuration:
main: src/backend/main.mosrc/backend/main.mo
src/backend/main.mo
motoko
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<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);
await ckbtcLedger.icrc1_transfer({
from_subaccount = ?fromSubaccount;
to = { owner = to; subaccount = null };
amount = amount;
fee = ?10; // 10 satoshis
memo = null;
created_at_time = null;
})
};
// -- 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 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 = null;
});
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;
})
};
};motoko
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<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);
await ckbtcLedger.icrc1_transfer({
from_subaccount = ?fromSubaccount;
to = { owner = to; subaccount = null };
amount = amount;
fee = ?10; // 10 satoshis
memo = null;
created_at_time = null;
})
};
// -- 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 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 = null;
});
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
Rust
Cargo.toml
Cargo.toml
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"] }
serde_bytes = "0.11"
icrc-ledger-types = "0.1"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"] }
serde_bytes = "0.11"
icrc-ledger-types = "0.1"src/lib.rs
src/lib.rs
rust
use candid::{CandidType, Deserialize, Nat, Principal};
use ic_cdk::update;
use ic_cdk::call::Call;
use icrc_ledger_types::icrc1::account::Account;
use icrc_ledger_types::icrc1::transfer::{TransferArg, TransferError};
use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError};
// -- Canister IDs --
const CKBTC_LEDGER: &str = "mxzaz-hqaaa-aaaar-qaada-cai";
const CKBTC_MINTER: &str = "mqygn-kiaaa-aaaar-qaadq-cai";
// -- Minter types --
#[derive(CandidType, Deserialize, Debug)]
struct GetBtcAddressArgs {
owner: Option<Principal>,
subaccount: Option<Vec<u8>>,
}
#[derive(CandidType, Deserialize, Debug)]
struct UpdateBalanceArgs {
owner: Option<Principal>,
subaccount: Option<Vec<u8>>,
}
#[derive(CandidType, Deserialize, Debug)]
struct RetrieveBtcWithApprovalArgs {
address: String,
amount: u64,
from_subaccount: Option<Vec<u8>>,
}
#[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<u8>,
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<Vec<PendingUtxo>>,
current_confirmations: Option<u32>,
},
AlreadyProcessing,
TemporarilyUnavailable(String),
GenericError { error_code: u64, error_message: String },
}
type UpdateBalanceResult = Result<Vec<UtxoStatus>, UpdateBalanceError>;
type RetrieveBtcResult = Result<RetrieveBtcOk, RetrieveBtcError>;
// -- 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<Nat, TransferError> {
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<Nat, TransferError>,) = 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<Nat, ApproveError>,) = 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!();rust
use candid::{CandidType, Deserialize, Nat, Principal};
use ic_cdk::update;
use ic_cdk::call::Call;
use icrc_ledger_types::icrc1::account::Account;
use icrc_ledger_types::icrc1::transfer::{TransferArg, TransferError};
use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError};
// -- Canister IDs --
const CKBTC_LEDGER: &str = "mxzaz-hqaaa-aaaar-qaada-cai";
const CKBTC_MINTER: &str = "mqygn-kiaaa-aaaar-qaadq-cai";
// -- Minter types --
#[derive(CandidType, Deserialize, Debug)]
struct GetBtcAddressArgs {
owner: Option<Principal>,
subaccount: Option<Vec<u8>>,
}
#[derive(CandidType, Deserialize, Debug)]
struct UpdateBalanceArgs {
owner: Option<Principal>,
subaccount: Option<Vec<u8>>,
}
#[derive(CandidType, Deserialize, Debug)]
struct RetrieveBtcWithApprovalArgs {
address: String,
amount: u64,
from_subaccount: Option<Vec<u8>>,
}
#[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<u8>,
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<Vec<PendingUtxo>>,
current_confirmations: Option<u32>,
},
AlreadyProcessing,
TemporarilyUnavailable(String),
GenericError { error_code: u64, error_message: String },
}
type UpdateBalanceResult = Result<Vec<UtxoStatus>, UpdateBalanceError>;
type RetrieveBtcResult = Result<RetrieveBtcOk, RetrieveBtcError>;
// -- 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<Nat, TransferError> {
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<Nat, TransferError>,) = 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<Nat, ApproveError>,) = 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.
本地环境没有ckBTC minter。本地测试时,可mock minter接口或直接在主网/测试网进行测试。
Deploy to Mainnet
部署到主网
bash
undefinedbash
undefinedDeploy your backend canister
部署你的后端canister
icp deploy backend -e ic
icp deploy backend -e ic
Your canister calls the mainnet ckBTC canisters directly by principal
你的canister会直接通过principal调用主网ckBTC canister
undefinedundefinedUsing icp to Interact with ckBTC Directly
使用icp直接与ckBTC交互
bash
undefinedbash
undefinedCheck ckBTC balance for an account
查询账户的ckBTC余额
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of
'(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
'(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of
'(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
'(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
Get deposit address
获取存入地址
icp canister call mqygn-kiaaa-aaaar-qaadq-cai get_btc_address
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
icp canister call mqygn-kiaaa-aaaar-qaadq-cai get_btc_address
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
Check for new deposits and mint ckBTC
检查新存入并铸造ckBTC
icp canister call mqygn-kiaaa-aaaar-qaadq-cai update_balance
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
icp canister call mqygn-kiaaa-aaaar-qaadq-cai update_balance
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })'
-e ic
Transfer ckBTC (amount in e8s — 1 ckBTC = 100_000_000)
转账ckBTC(金额单位为e8s — 1 ckBTC = 100_000_000)
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer
'(record { to = record { owner = principal "RECIPIENT-PRINCIPAL"; subaccount = null }; amount = 100_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic
'(record { to = record { owner = principal "RECIPIENT-PRINCIPAL"; subaccount = null }; amount = 100_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer
'(record { to = record { owner = principal "RECIPIENT-PRINCIPAL"; subaccount = null }; amount = 100_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic
'(record { to = record { owner = principal "RECIPIENT-PRINCIPAL"; subaccount = null }; amount = 100_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic
Withdraw ckBTC to a BTC address (amount in satoshis, minimum 50_000)
将ckBTC提取为BTC(金额单位为聪,最低50_000)
Note: In production, use icrc2_approve + retrieve_btc_with_approval (see withdraw function above)
注意:生产环境中请使用icrc2_approve + retrieve_btc_with_approval(参考上述withdraw函数)
icp canister call mqygn-kiaaa-aaaar-qaadq-cai retrieve_btc_with_approval
'(record { address = "bc1q...your-btc-address"; amount = 50_000; from_subaccount = null })'
-e ic
'(record { address = "bc1q...your-btc-address"; amount = 50_000; from_subaccount = null })'
-e ic
icp canister call mqygn-kiaaa-aaaar-qaadq-cai retrieve_btc_with_approval
'(record { address = "bc1q...your-btc-address"; amount = 50_000; from_subaccount = null })'
-e ic
'(record { address = "bc1q...your-btc-address"; amount = 50_000; from_subaccount = null })'
-e ic
Check transfer fee
查询转账手续费
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_fee '()' -e ic
undefinedicp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_fee '()' -e ic
undefinedVerify It Works
验证功能
Check Balance
检查余额
bash
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \
'(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \
-e icbash
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \
'(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \
-e icExpected: (AMOUNT : nat) — balance in satoshis (e8s)
预期结果:(AMOUNT : nat) — 余额单位为聪(e8s)
undefinedundefinedVerify Transfer
验证转账
bash
undefinedbash
undefinedTransfer 1000 satoshis
转账1000聪
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer
'(record { to = record { owner = principal "RECIPIENT"; subaccount = null }; amount = 1_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic
'(record { to = record { owner = principal "RECIPIENT"; subaccount = null }; amount = 1_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer
'(record { to = record { owner = principal "RECIPIENT"; subaccount = null }; amount = 1_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic
'(record { to = record { owner = principal "RECIPIENT"; subaccount = null }; amount = 1_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic
Expected: (variant { Ok = BLOCK_INDEX : nat })
预期结果:(variant { Ok = BLOCK_INDEX : nat })
Verify recipient received it
验证接收方是否收到
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of
'(record { owner = principal "RECIPIENT"; subaccount = null })'
-e ic
'(record { owner = principal "RECIPIENT"; subaccount = null })'
-e ic
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of
'(record { owner = principal "RECIPIENT"; subaccount = null })'
-e ic
'(record { owner = principal "RECIPIENT"; subaccount = null })'
-e ic
Expected: balance increased by 1000
预期结果:余额增加1000聪
undefinedundefinedVerify Deposit Flow
验证存入流程
bash
undefinedbash
undefined1. Get deposit address
1. 获取存入地址
icp canister call YOUR-CANISTER getDepositAddress -e ic
icp canister call YOUR-CANISTER getDepositAddress -e ic
Expected: "bc1q..." or "3..." — a valid Bitcoin address
预期结果:"bc1q..." 或 "3..." — 有效的Bitcoin地址
2. Send BTC to that address (external wallet)
2. 使用外部钱包向该地址发送BTC
3. Check for new deposits
3. 检查新存入
icp canister call YOUR-CANISTER updateBalance -e ic
icp canister call YOUR-CANISTER updateBalance -e ic
Expected: (variant { Ok = vec { variant { Minted = record { ... } } } })
预期结果:(variant { Ok = vec { variant { Minted = record { ... } } } })
4. Check ckBTC balance
4. 检查ckBTC余额
icp canister call YOUR-CANISTER getBalance -e ic
icp canister call YOUR-CANISTER getBalance -e ic
Expected: balance reflects minted ckBTC
预期结果:余额反映铸造的ckBTC数量
undefinedundefinedVerify Withdrawal
验证提取
bash
icp canister call YOUR-CANISTER withdraw '("bc1q...destination", 50_000 : nat64)' -e icbash
icp canister call YOUR-CANISTER withdraw '("bc1q...destination", 50_000 : nat64)' -e icExpected: (variant { Ok = record { block_index = BLOCK_INDEX : nat64 } })
预期结果:(variant { Ok = record { block_index = BLOCK_INDEX : nat64 } })
The BTC will arrive at the destination address after Bitcoin confirmations
经过Bitcoin网络确认后,BTC会到达目标地址
undefinedundefined