diff --git a/docs/guides/defi/chain-key-tokens.md b/docs/guides/defi/chain-key-tokens.md deleted file mode 100644 index aa9d2c48..00000000 --- a/docs/guides/defi/chain-key-tokens.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "Chain-Key Tokens" -description: "Work with ckBTC, ckETH, and other chain-key token representations" -sidebar: - order: 2 ---- - -TODO: Write content for this page. - - -Work with chain-key tokens (ckBTC, ckETH) that represent assets from other chains on ICP. Cover minting (depositing BTC/ETH to get ckBTC/ckETH), redemption (burning ck tokens to withdraw), ledger interaction for transfers, and subaccount derivation for user deposits. Explain the trust model and how chain-key tokens maintain their peg. - - -- Portal: defi/chain-key-tokens/ files -- icskills: ckbtc -- JS SDK: @icp-sdk/canisters (https://js.icp.build/canisters) -- Examples: token_transfer (both) -- Learn Hub: [Chain-Key Tokens](https://learn.internetcomputer.org/hc/en-us/articles/34211397080980) - - -- guides/chain-fusion/bitcoin -- native BTC integration (alternative to ckBTC) -- guides/chain-fusion/ethereum -- native ETH integration (alternative to ckETH) -- guides/defi/token-ledgers -- ck tokens are ICRC-1 tokens diff --git a/docs/guides/defi/chain-key-tokens.mdx b/docs/guides/defi/chain-key-tokens.mdx new file mode 100644 index 00000000..513de9e6 --- /dev/null +++ b/docs/guides/defi/chain-key-tokens.mdx @@ -0,0 +1,629 @@ +--- +title: "Chain-Key Tokens" +description: "Work with ckBTC and ckETH — ICP-native representations of Bitcoin and Ether with 1-2 second finality and no custodians" +sidebar: + order: 2 +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +Chain-key tokens are ICP-native tokens that represent assets from other blockchains. Each one is backed 1:1 by the original asset and is controlled entirely by ICP smart contracts — no bridges, no wrapped tokens, no third-party custodians. + +**ckBTC** (chain-key Bitcoin) is backed by real BTC held by the ckBTC minter canister. **ckETH** (chain-key Ether) is backed by real ETH held by the ckETH minter canister. Both are ICRC-1 tokens, so any code that works with the ICP ledger also works with ckBTC and ckETH — you only swap the canister ID. + +This guide covers: the minting and redemption flows, how to call the minter and ledger from a canister, subaccount derivation for per-user deposit addresses, and the trust model that keeps the peg. + +For plain ICRC-1/ICRC-2 transfers without the minting/withdrawal flows, see [Token ledgers](token-ledgers.md). + +## How chain-key tokens maintain their peg + +The ckBTC and ckETH minter canisters are ICP smart contracts that hold real BTC and ETH in addresses they control through [chain-key cryptography](../../concepts/chain-key-cryptography.md). The minters use threshold signatures to sign Bitcoin and Ethereum transactions — no private key exists anywhere; signing requires cooperation from the subnet's nodes. + +When a user deposits BTC, the minter mints exactly the same amount of ckBTC. When a user withdraws ckBTC, the minter burns the tokens and sends BTC on-chain. The peg holds by design: every ckBTC in circulation corresponds to exactly one satoshi of BTC held by the minter. The ckBTC checker canister publishes a public audit of reserves. + +This means ckBTC and ckETH are not wrapped tokens in the traditional sense. They are ICP tokens whose supply is cryptographically enforced by the minter canister. + +## Canister IDs + +### ckBTC + +| Canister | Mainnet 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** (for testing): + +| Canister | Testnet4 ID | +|----------|------------| +| ckBTC Ledger | `mc6ru-gyaaa-aaaar-qaaaq-cai` | +| ckBTC Minter | `ml52i-qqaaa-aaaar-qaaba-cai` | +| ckBTC Index | `mm444-5iaaa-aaaar-qaabq-cai` | + +### ckETH + +| Canister | Mainnet ID | +|----------|-----------| +| ckETH Ledger | `ss2fx-dyaaa-aaaar-qacoq-cai` | +| ckETH Minter | `sv3dd-oaaaa-aaaar-qacoa-cai` | +| ckETH Index | `s3zol-vqaaa-aaaar-qacpa-cai` | + +> Always query `icrc1_fee` at runtime rather than hardcoding. ckBTC uses satoshi units (1 BTC = 100,000,000 satoshis, fee = 10 satoshis). ckETH uses wei units (1 ETH = 10¹⁸ wei). + +## Deposit flow: getting ckBTC from BTC + +The deposit flow has two steps: + +1. **Get a deposit address** — call `get_btc_address` on the ckBTC minter with the user's principal and an optional subaccount. The minter returns a unique Bitcoin address. +2. **Mint ckBTC** — after the user sends BTC to that address, call `update_balance` on the minter. The minter checks for new UTXOs and mints ckBTC to the corresponding ICRC-1 account. + +The minter requires a minimum number of Bitcoin confirmations before minting (currently 6 on mainnet). `update_balance` returns `NoNewUtxos` if confirmations have not yet been reached — your app should poll or prompt the user to wait. + + + + +```motoko +import Principal "mo:core/Principal"; +import Blob "mo:core/Blob"; +import Nat8 "mo:core/Nat8"; +import Array "mo:core/Array"; +import Runtime "mo:core/Runtime"; + +persistent actor Self { + + // Types for the ckBTC minter interface + 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 : ?[{ outpoint : { txid : Blob; vout : Nat32 }; value : Nat64; confirmations : Nat32 }]; + current_confirmations : ?Nat32; + }; + #AlreadyProcessing; + #TemporarilyUnavailable : Text; + #GenericError : { error_code : Nat64; error_message : Text }; + }; + + // ckBTC minter — mainnet + transient let ckbtcMinter : actor { + get_btc_address : shared ({ owner : ?Principal; subaccount : ?Blob }) -> async Text; + update_balance : shared ({ owner : ?Principal; subaccount : ?Blob }) -> async UpdateBalanceResult; + } = actor "mqygn-kiaaa-aaaar-qaadq-cai"; + + // 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(); + let sub = Array.tabulate(32, func(i : Nat) : Nat8 { + if (i == 0) { Nat8.fromNat(size) } + else if (i <= size) { bytes[i - 1] } + else { 0 } + }); + Blob.fromArray(sub) + }; + + // Get the user's unique 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; + }) + }; + + // Check for new BTC deposits and mint ckBTC + public shared ({ caller }) func checkForDeposit() : async UpdateBalanceResult { + if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; + let subaccount = principalToSubaccount(caller); + await ckbtcMinter.update_balance({ + owner = ?Principal.fromActor(Self); + subaccount = ?subaccount; + }) + }; +} +``` + + + + +```rust +use candid::{CandidType, Deserialize, Principal}; +use ic_cdk::update; +use ic_cdk::call::Call; + +const CKBTC_MINTER: &str = "mqygn-kiaaa-aaaar-qaadq-cai"; + +#[derive(CandidType, Deserialize, Debug)] +struct GetBtcAddressArgs { + owner: Option, + subaccount: Option>, +} + +#[derive(CandidType, Deserialize, Debug)] +struct UpdateBalanceArgs { + owner: Option, + subaccount: Option>, +} + +// 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 minter_id() -> Principal { + Principal::from_text(CKBTC_MINTER).unwrap() +} + +// Get the user's unique 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 +} +``` + + + + +## Withdrawal flow: converting ckBTC back to BTC + +To convert ckBTC back to BTC, your canister must: + +1. **Approve the minter** — call `icrc2_approve` on the ckBTC ledger, granting the minter canister an allowance to burn ckBTC from the user's account. The amount must include the transfer fee. +2. **Request withdrawal** — call `retrieve_btc_with_approval` on the minter with the destination Bitcoin address and the amount in satoshis. The minimum withdrawal amount is 50,000 satoshis (0.0005 BTC). + +The minter burns the ckBTC and submits a Bitcoin transaction. BTC arrives at the destination address after Bitcoin confirmations (typically 1-2 hours on mainnet). + + + + +```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 Int "mo:core/Int"; +import Time "mo:core/Time"; +import Array "mo:core/Array"; +import Runtime "mo:core/Runtime"; + +persistent actor Self { + + type Account = { owner : Principal; subaccount : ?Blob }; + + type ApproveArg = { + 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 }; + }; + + transient let ckbtcLedger : actor { + icrc2_approve : shared (ApproveArg) -> async { #Ok : Nat; #Err : ApproveError }; + } = actor "mxzaz-hqaaa-aaaar-qaada-cai"; + + transient let ckbtcMinter : actor { + retrieve_btc_with_approval : shared (RetrieveBtcWithApprovalArgs) -> async RetrieveBtcResult; + } = actor "mqygn-kiaaa-aaaar-qaadq-cai"; + + func principalToSubaccount(p : Principal) : Blob { + let bytes = Blob.toArray(Principal.toBlob(p)); + let size = bytes.size(); + let sub = Array.tabulate(32, func(i : Nat) : Nat8 { + if (i == 0) { Nat8.fromNat(size) } + else if (i <= size) { bytes[i - 1] } + else { 0 } + }); + Blob.fromArray(sub) + }; + + // Withdraw ckBTC to a Bitcoin address (minimum 50,000 satoshis) + public shared ({ caller }) func withdrawToBtc(btcAddress : Text, amount : Nat64) : async RetrieveBtcResult { + if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; + let fromSubaccount = principalToSubaccount(caller); + let minterPrincipal = Principal.fromText("mqygn-kiaaa-aaaar-qaadq-cai"); + // Set created_at_time for deduplication: two identical approvals within 24h + // would both execute without this. Omit if you intentionally allow retries. + let now = ?Nat64.fromNat(Int.abs(Time.now())); + + // Step 1: approve the minter to spend ckBTC (amount + fee) + let approveResult = await ckbtcLedger.icrc2_approve({ + from_subaccount = ?fromSubaccount; + spender = { owner = minterPrincipal; subaccount = null }; + amount = Nat64.toNat(amount) + 10; // amount + 10 satoshi fee for the burn + expected_allowance = null; + expires_at = null; + fee = ?10; + memo = null; + created_at_time = now; + }); + + switch (approveResult) { + case (#Err(_)) { return #Err(#TemporarilyUnavailable("Approve for minter failed")) }; + case (#Ok(_)) {}; + }; + + // Step 2: request the withdrawal + await ckbtcMinter.retrieve_btc_with_approval({ + address = btcAddress; + amount = amount; + from_subaccount = ?fromSubaccount; + }) + }; +} +``` + + + + +```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::icrc2::approve::{ApproveArgs, ApproveError}; + +const CKBTC_LEDGER: &str = "mxzaz-hqaaa-aaaar-qaada-cai"; +const CKBTC_MINTER: &str = "mqygn-kiaaa-aaaar-qaadq-cai"; + +#[derive(CandidType, Deserialize, Debug)] +struct RetrieveBtcWithApprovalArgs { + address: String, + amount: u64, + from_subaccount: Option>, +} + +#[derive(CandidType, Deserialize, Debug)] +struct RetrieveBtcOk { + block_index: u64, +} + +#[derive(CandidType, Deserialize, Debug)] +enum RetrieveBtcError { + MalformedAddress(String), + AlreadyProcessing, + AmountTooLow(u64), + InsufficientFunds { balance: u64 }, + InsufficientAllowance { allowance: u64 }, + TemporarilyUnavailable(String), + GenericError { error_code: u64, error_message: String }, +} + +type RetrieveBtcResult = Result; + +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 +} + +// Withdraw ckBTC to a Bitcoin address (minimum 50,000 satoshis) +#[update] +async fn withdraw_to_btc(btc_address: String, amount: u64) -> RetrieveBtcResult { + let caller = ic_cdk::api::msg_caller(); + assert_ne!(caller, Principal::anonymous(), "Authentication required"); + + let from_subaccount = principal_to_subaccount(&caller); + let ledger = Principal::from_text(CKBTC_LEDGER).unwrap(); + let minter = Principal::from_text(CKBTC_MINTER).unwrap(); + // Set created_at_time for deduplication: two identical approvals within 24h + // would both execute without this. Omit if you intentionally allow retries. + let now = Some(ic_cdk::api::time()); + + // Step 1: approve the minter to spend ckBTC (amount + fee) + let approve_args = ApproveArgs { + from_subaccount: Some(from_subaccount), + spender: Account { owner: minter, subaccount: None }, + amount: Nat::from(amount) + Nat::from(10u64), // amount + 10 satoshi fee for the burn + expected_allowance: None, + expires_at: None, + fee: Some(Nat::from(10u64)), + memo: None, + created_at_time: now, + }; + + let (approve_result,): (Result,) = Call::unbounded_wait(ledger, "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 failed: {:?}", e), + }); + } + + // Step 2: request the withdrawal + let args = RetrieveBtcWithApprovalArgs { + address: btc_address, + amount, + from_subaccount: Some(from_subaccount.to_vec()), + }; + + let (result,): (RetrieveBtcResult,) = Call::unbounded_wait(minter, "retrieve_btc_with_approval") + .with_arg(args) + .await + .expect("Failed to call retrieve_btc_with_approval") + .candid_tuple() + .expect("Failed to decode response"); + + result +} +``` + + + + +## ckETH: deposit and withdrawal + +The ckETH minter works similarly to ckBTC but targets Ethereum. Deposits are detected via HTTPS outcalls to Ethereum RPC nodes — the minter monitors a helper contract for ETH transfers and mints ckETH when it detects them. + +### Depositing ETH to get ckETH + +1. Call the `deposit` function on the shared ckETH helper smart contract on Ethereum (mainnet address: `0x6abDA0438307733FC299e9C229FD3cc074bD8cC0`), passing some ETH and your ICP principal as the call data. The helper contract emits a `ReceivedEth` event with the sender, value, and receiver (your principal) as payload. +2. The ckETH minter monitors `ReceivedEth` events by periodically fetching logs from Ethereum RPC providers. When it detects the event, it mints the corresponding amount of ckETH to your ICRC-1 account. + +> The ckETH deposit flow requires interacting with an Ethereum wallet or library. For sending Ethereum transactions from an ICP canister, see [Ethereum integration](../chain-fusion/ethereum.md). + +### Withdrawing ckETH to ETH + +The ckETH withdrawal flow is the same approve-then-request pattern as ckBTC: + +1. Call `icrc2_approve` on the ckETH ledger, granting the ckETH minter an allowance. +2. Call `withdraw_eth` on the ckETH minter with a destination Ethereum address and the amount in wei. + +```bash +# Check ckETH balance (amount in wei) +icp canister call ss2fx-dyaaa-aaaar-qacoq-cai icrc1_balance_of \ + '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic + +# Transfer ckETH (amount in wei) +icp canister call ss2fx-dyaaa-aaaar-qacoq-cai icrc1_transfer \ + '(record { + to = record { owner = principal "RECIPIENT-PRINCIPAL"; subaccount = null }; + amount = 1_000_000_000_000_000 : nat; + fee = opt (2_000_000_000_000 : nat); + memo = null; + from_subaccount = null; + created_at_time = null; + })' -e ic +``` + +> Query `icrc1_fee` on the ckETH ledger before transferring — the fee is denominated in wei and can change. + +## Transferring chain-key tokens + +ckBTC and ckETH are ICRC-1 tokens. Transfers work the same as any ICRC-1 transfer — call `icrc1_transfer` on the respective ledger. The only difference is the canister ID and the fee. + +```bash +# Check ckBTC balance (amount in satoshis) +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ + '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic + +# Check ckBTC transfer fee (in satoshis) +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_fee '()' -e ic + +# Transfer ckBTC (amounts in satoshis; 1 BTC = 100,000,000 satoshis) +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer \ + '(record { + to = record { owner = principal "RECIPIENT-PRINCIPAL"; subaccount = null }; + amount = 100_000 : nat; + fee = opt 10; + memo = null; + from_subaccount = null; + created_at_time = null; + })' -e ic +``` + +For Motoko and Rust transfer examples, see [Token ledgers](token-ledgers.md) — the code is identical to ICRC-1 transfers, just with the ckBTC or ckETH ledger canister ID and the correct fee. + +## Subaccount derivation for deposit flows + +In a typical deposit flow, each user gets a unique deposit subaccount derived from their principal. This lets a single canister manage many users' deposit addresses without deploying separate canisters. + +The standard derivation encodes the principal's length in the first byte, then copies the principal bytes, zero-padding to 32 bytes: + + + + +```motoko +import Principal "mo:core/Principal"; +import Blob "mo:core/Blob"; +import Nat8 "mo:core/Nat8"; +import Array "mo:core/Array"; + +type Account = { owner : Principal; subaccount : ?Blob }; + +// Encode a principal as a 32-byte subaccount (length-prefixed, zero-padded) +func principalToSubaccount(p : Principal) : Blob { + let bytes = Blob.toArray(Principal.toBlob(p)); + let size = bytes.size(); + let sub = Array.tabulate(32, func(i : Nat) : Nat8 { + if (i == 0) { Nat8.fromNat(size) } + else if (i <= size) { bytes[i - 1] } + else { 0 } + }); + Blob.fromArray(sub) +}; + +// Account for a user's deposit slot within this canister +func userDepositAccount(canister : Principal, user : Principal) : Account { + { owner = canister; subaccount = ?principalToSubaccount(user) } +}; +``` + + + + +```rust +use candid::Principal; +use icrc_ledger_types::icrc1::account::Account; + +/// Encode a principal as a 32-byte subaccount (length-prefixed, zero-padded) +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 +} + +/// Account for a user's deposit slot within this canister +fn user_deposit_account(canister: Principal, user: &Principal) -> Account { + Account { + owner: canister, + subaccount: Some(principal_to_subaccount(user)), + } +} +``` + + + + +When calling `get_btc_address`, pass: + +- `owner`: your canister's principal (`Principal.fromActor(Self)` in Motoko, `ic_cdk::api::canister_self()` in Rust) +- `subaccount`: the derived subaccount for the user + +The minter uses `(owner, subaccount)` to determine the Bitcoin address. When a deposit arrives at that address and you call `update_balance` with the same `(owner, subaccount)`, the minter mints ckBTC to the corresponding ICRC-1 account. + +## Common pitfalls + +**Using the wrong minter canister ID.** The ckBTC minter is `mqygn-kiaaa-aaaar-qaadq-cai`. Do not confuse it with the ledger (`mxzaz-...`) or index (`n5wcd-...`). Calling `update_balance` or `get_btc_address` on the ledger will fail or return unexpected results. + +**Not calling `update_balance` after a BTC deposit.** The minter does not auto-detect deposits. After a user sends BTC to the deposit address, your application must call `update_balance` to trigger minting. + +**Forgetting the minimum withdrawal amount.** The ckBTC minter rejects withdrawals below 50,000 satoshis (0.0005 BTC) with `AmountTooLow`. Always validate the amount before calling `retrieve_btc_with_approval`. + +**Omitting the owner in `get_btc_address`.** If you omit `owner`, the minter uses the caller's principal (your canister principal), not the end user's principal. The resulting deposit address will credit your canister's default account rather than the user's subaccount. + +**Transfer fee pitfall.** The fee is deducted from the sender's account on top of the amount. If a user has exactly 1,000 satoshis and you transfer 1,000, the transfer fails with `InsufficientFunds`. Transfer `balance - fee` to send the full balance. + +**Subaccount must be exactly 32 bytes.** Passing a shorter or longer subaccount causes a trap in the minter. Always pad to 32 bytes. + +## Checking balances via CLI + +```bash +# ckBTC balance +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ + '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic + +# ckETH balance +icp canister call ss2fx-dyaaa-aaaar-qacoq-cai icrc1_balance_of \ + '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic + +# ckBTC deposit address (calls the minter) +icp canister call mqygn-kiaaa-aaaar-qaadq-cai get_btc_address \ + '(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic + +# Check for new BTC deposits (calls the minter) +icp canister call mqygn-kiaaa-aaaar-qaadq-cai update_balance \ + '(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic +``` + +## ckBTC vs native Bitcoin integration + +Chain-key tokens and native chain integration serve different use cases: + +| | ckBTC | Native Bitcoin | +|-|-------|----------------| +| Settlement | 1–2 seconds | Minutes (Bitcoin confirmations) | +| Use case | Token transfers, DeFi, payments | Direct UTXO access, custom signing | +| Custody | Minter canister (ICP smart contract) | Your canister directly | +| Fee | 10 satoshis per ckBTC transfer | Bitcoin network fees | + +If you need direct control over Bitcoin UTXOs or want to construct custom Bitcoin transactions, see [Bitcoin integration](../chain-fusion/bitcoin.md). If you need fast, low-fee token transfers within ICP dapps, ckBTC is the simpler choice. + +## Next steps + +- [Token ledgers](token-ledgers.md) — ICRC-1/ICRC-2 transfer patterns for all tokens, including ckBTC and ckETH +- [Bitcoin integration](../chain-fusion/bitcoin.md) — native BTC UTXO access and threshold signing +- [Ethereum integration](../chain-fusion/ethereum.md) — calling Ethereum contracts from ICP canisters +- [Wallet integration](wallet-integration.md) — connecting wallets for token flows +- [Token standards](../../reference/token-standards.md) — ICRC-1 and ICRC-2 formal specifications + +{/* Upstream: informed by dfinity/icskills skills/ckbtc/SKILL.md; dfinity/icskills skills/icrc-ledger/SKILL.md; dfinity/portal docs/defi/chain-key-tokens/cketh/overview.mdx */}