From a96f17393604e00105735250f439c35c63f495f5 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 16 Apr 2026 15:31:35 +0200 Subject: [PATCH 1/2] docs: chain-key tokens guide Covers ckBTC and ckETH minting (deposit), redemption (withdrawal), ledger transfers, and subaccount derivation. Includes Motoko and Rust implementations with language tabs. Renamed stub from .md to .mdx for language tabs. --- docs/guides/defi/chain-key-tokens.md | 23 - docs/guides/defi/chain-key-tokens.mdx | 621 ++++++++++++++++++++++++++ 2 files changed, 621 insertions(+), 23 deletions(-) delete mode 100644 docs/guides/defi/chain-key-tokens.md create mode 100644 docs/guides/defi/chain-key-tokens.mdx 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..44ff5b08 --- /dev/null +++ b/docs/guides/defi/chain-key-tokens.mdx @@ -0,0 +1,621 @@ +--- +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](https://learn.internetcomputer.org/hc/en-us/articles/34211397080980). 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` | + +**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 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"); + + // 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 = null; + }); + + 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(); + + // 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: None, + }; + + 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 `minter_address` on the ckETH minter to get the Ethereum address of the ckETH helper contract. This address is unique per ICP principal. +2. Transfer ETH from your Ethereum wallet to the helper contract address, using your ICP principal as the call data so the minter can associate the deposit with your account. +3. The minter detects the deposit via HTTPS outcalls and mints 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; learn.internetcomputer.org/hc/en-us/articles/34211397080980 */} From 740428d865aea64a32c0709e3196614b4f09d449 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 16 Apr 2026 17:09:08 +0200 Subject: [PATCH 2/2] fix(chain-key-tokens): address PR #76 review feedback - Fix factually incorrect ckETH deposit flow: remove false minter_address function claim and unique-per-principal address claim; describe the correct flow using the deposit function on the shared helper contract with ICP principal as call data, and the minter monitoring ReceivedEth events - Change chain-key cryptography link from external Learn Hub URL to internal page (docs/concepts/chain-key-cryptography.md) per CLAUDE.md linking rules - Add ckBTC Checker canister ID (oltsj-fqaaa-aaaar-qal5q-cai) to the canister table since the page already references the checker's public audit - Add created_at_time for dedup protection in withdrawal code (Motoko and Rust) with explanatory comment per icrc-ledger skill pitfall #5 - Update Upstream comment to reference .sources/ repos instead of external URL --- docs/guides/defi/chain-key-tokens.mdx | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/guides/defi/chain-key-tokens.mdx b/docs/guides/defi/chain-key-tokens.mdx index 44ff5b08..513de9e6 100644 --- a/docs/guides/defi/chain-key-tokens.mdx +++ b/docs/guides/defi/chain-key-tokens.mdx @@ -17,7 +17,7 @@ For plain ICRC-1/ICRC-2 transfers without the minting/withdrawal flows, see [Tok ## 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](https://learn.internetcomputer.org/hc/en-us/articles/34211397080980). The minters use threshold signatures to sign Bitcoin and Ethereum transactions — no private key exists anywhere; signing requires cooperation from the subnet's nodes. +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. @@ -32,6 +32,7 @@ This means ckBTC and ckETH are not wrapped tokens in the traditional sense. They | 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): @@ -221,6 +222,8 @@ 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"; @@ -292,6 +295,9 @@ persistent actor Self { 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({ @@ -302,7 +308,7 @@ persistent actor Self { expires_at = null; fee = ?10; memo = null; - created_at_time = null; + created_at_time = now; }); switch (approveResult) { @@ -375,6 +381,9 @@ async fn withdraw_to_btc(btc_address: String, amount: u64) -> RetrieveBtcResult 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 { @@ -385,7 +394,7 @@ async fn withdraw_to_btc(btc_address: String, amount: u64) -> RetrieveBtcResult expires_at: None, fee: Some(Nat::from(10u64)), memo: None, - created_at_time: None, + created_at_time: now, }; let (approve_result,): (Result,) = Call::unbounded_wait(ledger, "icrc2_approve") @@ -429,9 +438,8 @@ The ckETH minter works similarly to ckBTC but targets Ethereum. Deposits are det ### Depositing ETH to get ckETH -1. Call `minter_address` on the ckETH minter to get the Ethereum address of the ckETH helper contract. This address is unique per ICP principal. -2. Transfer ETH from your Ethereum wallet to the helper contract address, using your ICP principal as the call data so the minter can associate the deposit with your account. -3. The minter detects the deposit via HTTPS outcalls and mints ckETH to your ICRC-1 account. +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). @@ -618,4 +626,4 @@ If you need direct control over Bitcoin UTXOs or want to construct custom Bitcoi - [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; learn.internetcomputer.org/hc/en-us/articles/34211397080980 */} +{/* 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 */}