Chain-Key Tokens
Chain-key tokens are ICP-native representations of assets from other blockchains. Each one is backed 1:1 by the original asset and controlled entirely by ICP canisters. No bridges, no wrapped tokens, no third-party custodians.
All chain-key tokens implement the ICRC-1 standard. The deposit and withdrawal flows use ICRC-2 icrc2_approve to authorize the minter to burn tokens on your behalf.
This guide covers deposit and withdrawal flows for each asset, plus the pattern for issuing per-user deposit addresses from a backend canister.
For plain ICRC-1/ICRC-2 transfers without the minting/withdrawal flows, see Ledgers.
Available chain-key assets
The current chain-key assets are: ckBTC (Bitcoin), ckETH (Ether), ckERC20 (ERC-20 tokens including ckUSDC, ckUSDT, ckLINK, and others), ckDOGE (Dogecoin), and ckSOL (Solana). Transfer code is identical across all of them: only the canister ID and fee denomination differ. See Canister IDs for the full list.
If you need direct Bitcoin UTXO access or custom Bitcoin transaction signing, see Bitcoin integration. If you need to call Ethereum contracts or interact with Ethereum infrastructure directly, see Ethereum integration. For fast ICP-native transfers, chain-key tokens are the simpler choice.
How chain-key tokens maintain their peg
ckBTC and ckETH are not wrapped assets in the traditional sense. The minter canisters hold the underlying BTC and ETH in addresses they control through chain-key cryptography, using threshold signatures to sign transactions. No private key exists anywhere. Every ckBTC in circulation corresponds to exactly one satoshi of BTC held by the minter.
ckBTC
Deposit: BTC → ckBTC
The deposit flow has two steps:
- Get a deposit address: call
get_btc_addresson the ckBTC minter with the owner principal and an optional subaccount. The minter returns a unique Bitcoin address. - Mint ckBTC: after BTC is sent to that address, call
update_balanceon 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 4 on mainnet). update_balance returns NoNewUtxos if confirmations have not yet been reached: your app should poll or prompt the user to wait.
import Principal "mo:core/Principal";
persistent actor Self {
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";
// Get the BTC deposit address for this canister's default account public shared func getDepositAddress() : async Text { await ckbtcMinter.get_btc_address({ owner = ?Principal.fromActor(Self); subaccount = null; }) };
// Check for new BTC deposits and mint ckBTC public shared func checkForDeposit() : async UpdateBalanceResult { await ckbtcMinter.update_balance({ owner = ?Principal.fromActor(Self); subaccount = null; }) };}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<Principal>, subaccount: Option<Vec<u8>>,}
fn minter_id() -> Principal { Principal::from_text(CKBTC_MINTER).unwrap()}
// Get the BTC deposit address for this canister's default account#[update]async fn get_deposit_address() -> String { let args = GetBtcAddressArgs { owner: Some(ic_cdk::api::canister_self()), subaccount: None, };
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}For per-user deposit addresses where each user gets a unique BTC address, see Per-user deposit addresses.
Withdrawal: ckBTC → BTC
To convert ckBTC back to BTC, your canister must:
- Approve the minter: call
icrc2_approveon the ckBTC ledger, granting the minter canister an allowance to burn ckBTC from the account. The amount must include the transfer fee. - Request withdrawal: call
retrieve_btc_with_approvalon 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).
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<Nat8>(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; }) };}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<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 },}
type RetrieveBtcResult = Result<RetrieveBtcOk, RetrieveBtcError>;
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<Nat, ApproveError>,) = 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 and ckERC20
The ckETH minter handles both ETH and ERC-20 deposits using a shared Ethereum helper smart contract. It monitors the contract for deposit events via HTTPS outcalls and mints the corresponding ckETH or ckERC20 to the target ICRC-1 account.
Always verify the helper contract address before any important transfer: call
get_minter_infoon the ckETH minter and checkdeposit_with_subaccount_helper_contract_address.
Deposit: ETH → ckETH
- Call
depositEthon the ckETH helper contract on Ethereum (mainnet:0x18901044688D3756C35Ed2b36D93e6a5B8e00E68), passing:- The amount of ETH.
- Your ICP principal encoded as
bytes32. - A 32-byte subaccount (
0xfor the default account, or a derived subaccount for per-user deposits — see Per-user deposit addresses).
- The minter detects the
ReceivedEthOrErc20event and mints ckETH to(principal, subaccount)after roughly 20 minutes.
The ckETH deposit flow requires an Ethereum wallet or library. For sending Ethereum transactions from an ICP canister, see Ethereum integration.
Deposit: ERC-20 → ckERC20
The ERC-20 deposit flow uses the same helper contract and minter:
- Call
approve(helper_address, amount)on the ERC-20 token contract to allow the helper contract to spend your tokens. - Call
depositErc20on the helper contract, passing:- The ERC-20 token contract address.
- The amount.
- Your ICP principal as
bytes32. - A 32-byte subaccount (
0xfor the default account).
- The minter detects the
ReceivedEthOrErc20event and mints the corresponding ckERC20 to(principal, subaccount).
Supported ERC-20 tokens on mainnet: USDC, USDT, EURC, WBTC, wstETH, LINK, UNI, SHIB, PEPE, XAUT, and OCT. For canister IDs, see Chain-Key Token Canister IDs. For the authoritative current list including any newly added tokens, call get_minter_info on the ckETH minter and check supported_ckerc20_tokens.
Withdrawal: ckETH or ckERC20 → ETH / ERC-20
The withdrawal flow follows the same approve-then-request pattern as ckBTC:
- Call
icrc2_approveon the respective ledger, granting the ckETH minter an allowance. - Call
withdraw_ethon the minter with a destination Ethereum address and the amount in wei. The same minter handles withdrawals for all ckERC20 tokens.
The minter burns the token and submits an Ethereum transaction. Funds arrive after Ethereum finalization, roughly 20 minutes on mainnet.
Query
icrc1_feeon the ledger before withdrawing. The ckETH fee is denominated in wei and can change.
ckDOGE
ckDOGE follows the same UTXO-based pattern as ckBTC. The minter issues a unique Dogecoin address per (owner, subaccount) account.
ckDOGE is in beta. The API is stable but the integration warrants careful observation during this period.
Deposit: DOGE → ckDOGE
- Call
get_deposit_addresson the ckDOGE minter (eqltq-xqaaa-aaaar-qb3vq-cai) with the owner principal and an optional subaccount. The minter returns a unique Dogecoin address. - Send DOGE to that address from any Dogecoin wallet.
- Call
update_balanceon the minter to trigger minting. The minter checks for confirmed UTXOs and mints ckDOGE to the corresponding ICRC-1 account.
update_balance returns NoNewUtxos if the required confirmations have not yet been reached.
Withdrawal: ckDOGE → DOGE
- Call
icrc2_approveon the ckDOGE ledger (efmc5-wyaaa-aaaar-qb3wa-cai), granting the minter an allowance. - Call
retrieve_doge_with_approvalon the minter with the destination Dogecoin address and the amount in koinus (1 DOGE = 100,000,000 koinus).
The minter burns ckDOGE and submits a signed Dogecoin transaction using threshold ECDSA.
For implementation details and the Candid interface, see the ckDOGE source.
ckSOL
ckSOL follows a similar pattern to ckBTC: the minter issues a unique Solana deposit address per (owner, subaccount) account.
Deposit: SOL → ckSOL
- Call
get_deposit_addresson the ckSOL minter with your ICP principal and optional subaccount. The minter returns a unique Solana address. - Send SOL to that address from any Solana wallet.
- Call
process_depositon the minter with the Solana transaction signature, owner principal, and subaccount (attach cycles to cover the RPC verification cost). The minter verifies the transaction via the SOL RPC canister and mints ckSOL to your account.
Withdrawal: ckSOL → SOL
- Call
icrc2_approveon the ckSOL ledger, granting the minter an allowance. - Call
withdrawon the minter with a destination Solana address and amount in lamports. The minter burns ckSOL and signs a Solana transaction using threshold Ed25519.
Track withdrawal progress with withdrawal_status using the burn block index returned from withdraw.
For canister IDs and further details, see the ckSOL repository.
Transferring chain-key tokens
All chain-key tokens 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 denomination.
# Check ckBTC balance (amount in satoshis)icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ -n ic
# Check ckBTC transfer fee (in satoshis)icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_fee '()' -n 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; })' -n icFor Motoko and Rust transfer examples, see Ledgers: the code is identical to ICRC-1 transfers, just with the respective ledger canister ID and fee.
Per-user deposit addresses
A backend canister serving multiple users needs each user to have a unique deposit address so that deposits can be credited to the correct account. The standard pattern: derive a 32-byte subaccount from the user’s principal and pass it to the deposit call.
For the derivation code (Motoko and Rust), see Working with subaccounts in the Ledgers guide. The subaccount derivation is identical across all ICRC-1 assets.
Pass the derived subaccount to the deposit call for each asset:
- ckBTC: pass as the
subaccountfield inget_btc_address. Use the same(owner, subaccount)pair inupdate_balanceto credit the correct account. - ckETH / ckERC20: encode as a
0x-prefixed hex string and pass todepositEthordepositErc20on the Ethereum helper contract. - ckDOGE: pass as the
subaccountfield inget_deposit_address. Use the same pair inupdate_balanceto credit the correct account. - ckSOL: pass as the
subaccountfield inget_deposit_address. Use the same pair inprocess_deposit.
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. Always query icrc1_fee at runtime rather than hardcoding. Each ledger uses its native unit: ckBTC uses satoshis (1 BTC = 100,000,000 satoshis), ckETH uses wei (1 ETH = 10¹⁸ wei), ckDOGE uses koinu (1 DOGE = 100,000,000 koinu).
Subaccount must be exactly 32 bytes. Passing a shorter or longer subaccount causes a trap in the minter. Always pad to 32 bytes.
Depositing an unsupported ERC-20 token. The ckETH helper contract does not enforce a token whitelist: funds sent for an unsupported token are lost. Always verify support via get_minter_info before any transfer.
Using an outdated ckETH helper contract address. The helper contract address can change when the minter is upgraded. Always verify the current address via get_minter_info on the ckETH minter, checking deposit_with_subaccount_helper_contract_address.
Checking balances via CLI
icrc1_balance_of works on any chain-key token ledger. Use the ckBTC ledger as an example:
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ -n icFor canister IDs for all other chain-key tokens, see Chain-Key Token Canister IDs.
Next steps
- Ledgers: transfer and manage digital assets, including all chain-key tokens
- Bitcoin integration: native BTC UTXO access and threshold signing
- Ethereum integration: calling Ethereum contracts from ICP canisters
- Wallet integration: add wallet signing to your app
- Digital Asset Standards: formal ICRC standard specifications for fungible assets, NFTs, and their extensions