mcpField
C
ckbtc

ckbtc

github.com/dfinity开发39274 次安装今天更新
代码仓库

关于 ckbtc

面向 Internet Computer 开发者的 ckBTC 集成技能,介绍如何用 Motoko 或 Rust 实现 BTC 充值、ckBTC 转账和 BTC 提现,并说明调用方式与常见交易问题。

主要功能

  1. 01BTC 充值与铸币流程
  2. 02ckBTC 账本转账
  3. 03BTC 提现流程
  4. 04Motoko 和 Rust 实现指南
  5. 05Canister ID 与测试说明

适用场景

  1. 01为 Internet Computer 应用添加 BTC 充值
  2. 02按正确费用和账户格式实现 ckBTC 转账
  3. 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 主网
CanisterID
ckBTC Ledgermxzaz-hqaaa-aaaar-qaada-cai
ckBTC Mintermqygn-kiaaa-aaaar-qaadq-cai
ckBTC Indexn5wcd-faaaa-aaaar-qaaea-cai
ckBTC Checkeroltsj-fqaaa-aaaar-qal5q-cai
Bitcoin Testnet4
CanisterID
ckBTC Ledgermc6ru-gyaaa-aaaar-qaaaq-cai
ckBTC Minterml52i-qqaaa-aaaar-qaaba-cai
ckBTC Indexmm444-5iaaa-aaaar-qaabq-cai

工作原理

充值流程(BTC -> ckBTC)
  1. 在 minter 上调用 get_btc_address,并传入用户的 principal 和子账户。该调用会返回一个由 minter 控制的唯一 Bitcoin 地址。
  2. 用户使用任意 Bitcoin 钱包向该地址发送 BTC。
  3. 等待 Bitcoin 确认(minter 需要交易获得确认后才会铸造代币)。
  4. 在 minter 上使用相同的 principal 和子账户调用 update_balance。minter 会检查新的 UTXO,并向用户的 ICRC-1 账户铸造等额 ckBTC。
转账流程(ckBTC -> ckBTC)

在 ckBTC ledger 上调用 icrc1_transfer。手续费为 10 聪。转账可在 1–2 秒内结算。

提现流程(ckBTC -> BTC)
  1. 在 ckBTC ledger 上调用 icrc2_approve,授权 minter canister 从你的账户支出。
  2. 在 minter 上调用 retrieve_btc_with_approval,并传入 { address, amount, from_subaccount: null }。
  3. minter 使用该授权销毁 ckBTC 并提交 Bitcoin 交易。
  4. Bitcoin 获得确认后,BTC 将到达目标地址。
子账户生成

每位用户都会获得一个由其 principal 和可选的 32 字节子账户派生出的唯一充值地址。若要在你的 canister 中为每位用户分配不同的充值地址,请根据用户专属标识(其 principal 或递增 ID)派生子账户。

会导致构建失败的错误

  1. 使用了错误的 minter canister ID。 minter ID 是 mqygn-kiaaa-aaaar-qaadq-cai。不要将它与 ledger(mxzaz-...)或 index(n5wcd-...)混淆。

  2. 忘记 10 satoshi 的转账手续费。 每次 icrc1_transfer 都会在金额之外扣除 10 satoshi。如果用户恰好有 1000 satoshi,而你转账 1000,就会因 InsufficientFunds 失败。请转账 balance - 10。

  3. 存入 BTC 后未调用 update_balance。 将 BTC 发送到存款地址后,必须调用 update_balance 才会生效。minter 不会自动检测存款。你的应用必须调用此方法。

  4. 使用了 Account Identifier,而不是 ICRC-1 Account。 ckBTC 使用 ICRC-1 标准:{ owner: Principal, subaccount: ?Blob }。不要使用 ICP ledger 中旧版的 AccountIdentifier(十六进制字符串)。

  5. 子账户必须恰好为 32 字节或 null。 传入长度不足或超过 32 字节的子账户会导致 trap。如果从较短的值派生子账户,请在前面补零。

  6. 使用低于最低金额的值调用 retrieve_btc。 minter 设有最低提现金额(目前为 50,000 satoshi / 0.0005 BTC)。低于此金额时,会收到 AmountTooLow。

  7. 未检查 retrieve_btc 响应中的错误。 响应是一个 variant:Ok 包含 { block_index },Err 包含 MalformedAddress、InsufficientFunds、TemporarilyUnavailable 等具体错误。务必匹配这两个分支。

  8. 在 get_btc_address 参数中忘记填写 owner。 如果省略 owner,Candid 子类型会将其设为 null,minter 因而返回调用方(canister)而非用户的存款地址。

  9. 在 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

评论

支持 Markdown