关于 ckbtc
面向 Internet Computer 开发者的 ckBTC 集成技能,介绍如何用 Motoko 或 Rust 实现 BTC 充值、ckBTC 转账和 BTC 提现,并说明调用方式与常见交易问题。
主要功能
- 01BTC 充值与铸币流程
- 02ckBTC 账本转账
- 03BTC 提现流程
- 04Motoko 和 Rust 实现指南
- 05Canister ID 与测试说明
适用场景
- 01为 Internet Computer 应用添加 BTC 充值
- 02按正确费用和账户格式实现 ckBTC 转账
- 03构建将 ckBTC 提现为 BTC 的应用流程
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dfinity/ icskills --skill ckbtc安装后在 Claude Code 中输入/ckbtc确认已加载。
详细介绍
Chain-Key Bitcoin(ckBTC)集成
简介
ckBTC 是一种由 BTC 按 1:1 资产支持、原生运行于 Internet Computer 的代币。无需桥接、包装或第三方托管方。ckBTC minter canister 持有真实 BTC,并负责铸造和销毁 ckBTC 代币。转账可在 1–2 秒内结算,手续费为 10 聪(而在 Bitcoin L1 上需要数分钟,手续费则高达数千聪)。
前置条件
- 对于 Motoko:
mops包管理器,且 mops.toml 中包含core = "2.0.0" - 对于 Rust:
ic-cdk、icrc-ledger-types、candid、serde
Canister ID
Bitcoin 主网
| 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 |
工作原理
充值流程(BTC -> ckBTC)
- 在 minter 上调用
get_btc_address,并传入用户的 principal 和子账户。该调用会返回一个由 minter 控制的唯一 Bitcoin 地址。 - 用户使用任意 Bitcoin 钱包向该地址发送 BTC。
- 等待 Bitcoin 确认(minter 需要交易获得确认后才会铸造代币)。
- 在 minter 上使用相同的 principal 和子账户调用
update_balance。minter 会检查新的 UTXO,并向用户的 ICRC-1 账户铸造等额 ckBTC。
转账流程(ckBTC -> ckBTC)
在 ckBTC ledger 上调用 icrc1_transfer。手续费为 10 聪。转账可在 1–2 秒内结算。
提现流程(ckBTC -> BTC)
- 在 ckBTC ledger 上调用
icrc2_approve,授权 minter canister 从你的账户支出。 - 在 minter 上调用
retrieve_btc_with_approval,并传入{ address, amount, from_subaccount: null }。 - minter 使用该授权销毁 ckBTC 并提交 Bitcoin 交易。
- Bitcoin 获得确认后,BTC 将到达目标地址。
子账户生成
每位用户都会获得一个由其 principal 和可选的 32 字节子账户派生出的唯一充值地址。若要在你的 canister 中为每位用户分配不同的充值地址,请根据用户专属标识(其 principal 或递增 ID)派生子账户。
会导致构建失败的错误
-
使用了错误的 minter canister ID。 minter ID 是
mqygn-kiaaa-aaaar-qaadq-cai。不要将它与 ledger(mxzaz-...)或 index(n5wcd-...)混淆。 -
忘记 10 satoshi 的转账手续费。 每次
icrc1_transfer都会在金额之外扣除 10 satoshi。如果用户恰好有 1000 satoshi,而你转账 1000,就会因InsufficientFunds失败。请转账balance - 10。 -
存入 BTC 后未调用
update_balance。 将 BTC 发送到存款地址后,必须调用update_balance才会生效。minter 不会自动检测存款。你的应用必须调用此方法。 -
使用了 Account Identifier,而不是 ICRC-1 Account。 ckBTC 使用 ICRC-1 标准:
{ owner: Principal, subaccount: ?Blob }。不要使用 ICP ledger 中旧版的AccountIdentifier(十六进制字符串)。 -
子账户必须恰好为 32 字节或 null。 传入长度不足或超过 32 字节的子账户会导致 trap。如果从较短的值派生子账户,请在前面补零。
-
使用低于最低金额的值调用
retrieve_btc。 minter 设有最低提现金额(目前为 50,000 satoshi / 0.0005 BTC)。低于此金额时,会收到AmountTooLow。 -
未检查
retrieve_btc响应中的错误。 响应是一个 variant:Ok包含{ block_index },Err包含MalformedAddress、InsufficientFunds、TemporarilyUnavailable等具体错误。务必匹配这两个分支。 -
在
get_btc_address参数中忘记填写owner。 如果省略owner,Candid 子类型会将其设为 null,minter 因而返回调用方(canister)而非用户的存款地址。 -
在
icrc1_transfer/icrc2_approve中将created_at_time留为 null。 ICRC-1 规范规定,ledger SHOULD NOT 对未设置created_at_time的交易进行去重,因此重试的转账会再次执行,两次转移由真实 BTC 支持的价值。请设置此字段,并在重试时使用相同的时间戳——只有所有参数完全相同时,ledger 才会匹配重复交易,因此重试时使用新的Time.now()会导致去重失效。已成功执行的重试会返回#Err(#Duplicate { duplicate_of }),这是成功,而非失败。
实现
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"
@dfinity/motoko@v5+ 方案通过 mops build <canister-name> 编译,因此 main 位于此处,而不在 icp.yaml 的 recipe.configuration 中。[canisters.<name>] 键必须与 icp.yaml 中的 canister name 完全一致。
icp.yaml
你的后端 canister 可直接通过 principal 调用 ckBTC ledger 和 minter,无需在本地部署 ckBTC canister。
canisters:
- name: backend
recipe:
type: "@dfinity/[email protected]"
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"] }
serde_bytes = "0.11"
icrc-ledger-types = "0.1"
src/lib.rs
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: Some(ic_cdk::api::time()), // required for dedup — see Pitfall 9
};
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: Some(ic_cdk::api::time()), // required for dedup — see Pitfall 9
};
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!();
部署与测试
本地开发
没有本地 ckBTC minter。进行本地测试时,请模拟 minter 接口,或在主网 / 测试网上进行测试。
部署到主网
# Deploy your backend canister
icp deploy backend -e ic
# Your canister calls the mainnet ckBTC canisters directly by principal
使用 icp 直接与 ckBTC 交互
# Check ckBTC balance for an account
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \
'(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
# Check for new deposits and mint ckBTC
icp canister call mqygn-kiaaa-aaaar-qaadq-cai update_balance \
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \
-e ic
# Transfer ckBTC (amount in e8s — 1 ckBTC = 100_000_000)
# created_at_time is null here: no dedup, which is fine for a one-off manual
# call but not for canister code — see Pitfall 9.
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
# Withdraw ckBTC to a BTC address (amount in satoshis, minimum 50_000)
# Note: In production, use icrc2_approve + retrieve_btc_with_approval (see withdraw function above)
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
# Check transfer fee
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_fee '()' -e ic
验证是否正常工作
检查余额
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \
'(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \
-e ic
# Expected: (AMOUNT : nat) — balance in satoshis (e8s)
验证转账
# Transfer 1000 satoshis
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
# Expected: (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
# Expected: balance increased by 1000
验证存款流程
# 1. Get deposit address
icp canister call YOUR-CANISTER getDepositAddress -e ic
# Expected: "bc1q..." or "3..." — a valid Bitcoin address
# 2. Send BTC to that address (external wallet)
# 3. Check for new deposits
icp canister call YOUR-CANISTER updateBalance -e ic
# Expected: (variant { Ok = vec { variant { Minted = record { ... } } } })
# 4. Check ckBTC balance
icp canister call YOUR-CANISTER getBalance -e ic
# Expected: balance reflects minted ckBTC
验证提现
icp canister call YOUR-CANISTER withdraw '("bc1q...destination", 50_000 : nat64)' -e ic
# Expected: (variant { Ok = record { block_index = BLOCK_INDEX : nat64 } })
# The BTC will arrive at the destination address after Bitcoin confirmations