ckbtc について
Internet Computer の開発者向けに、Motoko または Rust で ckBTC を統合するスキルです。BTC の入金、ckBTC の送金、BTC の出金手順と、呼び出し方や取引上の注意点を扱います。
主な機能
- 01BTC の入金・ミントフロー
- 02ckBTC 台帳での送金
- 03BTC の出金フロー
- 04Motoko と Rust の実装ガイド
- 05Canister ID とテストの説明
活用シーン
- 01Internet Computer アプリへの BTC 入金機能の追加
- 02正しい手数料とアカウント形式での ckBTC 送金実装
- 03ckBTC を BTC として出金するアプリフローの構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dfinity/ icskills --skill ckbtcインストール後、Claude Code で/ckbtcを入力して読み込まれたか確認します。
詳細
Chain-Key Bitcoin(ckBTC)の統合
概要
ckBTCは、Internet Computerネイティブの、1対1でBTCに裏付けられたトークンです。ブリッジもラップも、第三者カストディアンも必要ありません。ckBTC minterカナisterは実際の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 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 |
仕組み
預け入れの流れ(BTC -> ckBTC)
- minterで
get_btc_addressをユーザーのprincipalとサブアカウントを指定して呼び出します。minterが管理する一意のBitcoinアドレスが返されます。 - ユーザーは任意のBitcoinウォレットを使って、そのアドレスにBTCを送金します。
- Bitcoinの承認を待ちます(minterはトークンを発行する前に承認を要求します)。
- 同じprincipalとサブアカウントを指定して、minterで
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)からサブアカウントを導出します。
ビルドを壊すよくある間違い
-
誤ったミンターのキャニスター ID を使用する。 ミンター ID は
mqygn-kiaaa-aaaar-qaadq-caiです。レジャー(mxzaz-...)やインデックス(n5wcd-...)と混同しないでください。 -
10 サトシの送金手数料を忘れる。 すべての
icrc1_transferでは、送金額とは別に 10 サトシが差し引かれます。ユーザーの残高がちょうど 1000 サトシのときに 1000 を送金すると、InsufficientFundsで失敗します。代わりにbalance - 10を送金してください。 -
BTC の入金後に
update_balanceを呼び出さない。 入金アドレスに BTC を送っても、update_balanceを呼び出すまでは何も起こりません。ミンターは入金を自動検出しません。アプリから呼び出す必要があります。 -
ICRC-1 Account の代わりに Account Identifier を使用する。 ckBTC は ICRC-1 標準を使用します:
{ owner: Principal, subaccount: ?Blob }。ICP レジャーの従来のAccountIdentifier(16 進数文字列)は使用しないでください。 -
サブアカウントは 32 バイトちょうど、または null にする必要があります。 32 バイトより短い、または長いサブアカウントを渡すとトラップが発生します。短い値から導出する場合は、先頭にゼロを埋めてください。
-
最小額未満の金額で
retrieve_btcを呼び出す。 ミンターには最小出金額(現在は 50,000 サトシ/0.0005 BTC)があります。これを下回ると、AmountTooLowが返されます。 -
retrieve_btcの応答でエラーを確認しない。 応答はバリアントです:Okには{ block_index }が含まれ、ErrにはMalformedAddress、InsufficientFunds、TemporarilyUnavailableなどの具体的なエラーが含まれます。必ず両方の分岐を処理してください。 -
get_btc_addressの引数でownerを指定し忘れる。ownerを省略すると、Candid のサブタイピングにより null が割り当てられ、ミンターはユーザーではなく呼び出し元(キャニスター)の入金アドレスを返します。 -
icrc1_transfer/icrc2_approveでcreated_at_timeを null のままにする。 ICRC-1 の仕様では、created_at_timeが未設定のトランザクションをレジャーは重複排除すべきではないとされています。そのため、再試行した送金が 2 回目も実行され、BTC に裏付けられた実際の価値が二重に移動します。値を設定し、再試行時には同じタイムスタンプを使用してください。レジャーが重複と判定するのはすべての引数が同一の場合だけなので、再試行時に新しい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 のキャニスター name と完全に一致させる必要があります。
icp.yaml
バックエンドのキャニスターからプリンシパルを直接指定して ckBTC のレジャーとミンターを呼び出せるため、ckBTC のローカルキャニスターをデプロイする必要はありません。
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 ミンターはありません。ローカルでテストする場合は、ミンターのインターフェースをモックするか、メインネット/テストネットでテストしてください。
メインネットへのデプロイ
# 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