Ledgers
Digital assets on ICP are managed by ledger canisters that implement the ICRC digital asset standards. The ICP ledger is fully ICRC-1 and ICRC-2 compliant: code that works with the ICP ledger works identically with ckBTC, ckETH, or any ICRC-1 compatible asset. You only need to swap the canister ID and fee. The ICRC specifications use the term “token” throughout their text.
Each ledger is paired with an index canister that continuously syncs the ledger’s blocks and provides efficient per-account transaction history queries. Together they form the ledger suite.
This guide covers the most common operations: transfers, approvals, subaccounts, transaction history, and local test ledger setup.
Well-known ledgers
| Asset | Ledger canister ID | Index canister ID |
|---|---|---|
| ICP | ryjl3-tyaaa-aaaaa-aaaba-cai | qhbym-qaaaa-aaaaa-aaafq-cai |
Query
icrc1_feeandicrc1_decimalsat runtime rather than hardcoding values.
For chain-key token canister IDs (ckBTC, ckETH, ckDOGE, ckSOL, and ckERC20), see Chain-Key Token Canister IDs. For chain-key token specifics (minting, deposits, withdrawals), see Chain-key tokens.
Transferring assets (ICRC-1)
The icrc1_transfer function sends tokens from the calling canister’s account to a destination account. Every ICRC-1 ledger uses the same Account type:
{ owner: Principal; subaccount: ?Blob } // 32-byte subaccount, null = defaultimport Principal "mo:core/Principal";import Nat "mo:core/Nat";import Nat64 "mo:core/Nat64";import Int "mo:core/Int";import Time "mo:core/Time";import Runtime "mo:core/Runtime";
persistent actor {
type Account = { owner : Principal; subaccount : ?Blob };
type TransferArg = { from_subaccount : ?Blob; to : Account; amount : Nat; fee : ?Nat; memo : ?Blob; created_at_time : ?Nat64; };
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 }; };
transient let icpLedger = actor ("ryjl3-tyaaa-aaaaa-aaaba-cai") : actor { icrc1_transfer : shared (TransferArg) -> async { #Ok : Nat; #Err : TransferError }; };
/// Transfer tokens from this canister's default account. /// WARNING: Add access control in production. public func sendTokens(to : Principal, amount : Nat) : async Nat { let now = Nat64.fromNat(Int.abs(Time.now())); let result = await icpLedger.icrc1_transfer({ from_subaccount = null; to = { owner = to; subaccount = null }; amount = amount; fee = ?10_000; memo = null; created_at_time = ?now; }); switch (result) { case (#Ok(blockIndex)) { blockIndex }; case (#Err(#InsufficientFunds({ balance }))) { Runtime.trap("Insufficient funds. Balance: " # Nat.toText(balance)) }; case (#Err(#BadFee({ expected_fee }))) { Runtime.trap("Wrong fee. Expected: " # Nat.toText(expected_fee)) }; case (#Err(_)) { Runtime.trap("Transfer failed") }; } };}Add these dependencies to Cargo.toml:
[dependencies]ic-cdk = "0.19"candid = "0.10"icrc-ledger-types = "0.1"use candid::{Nat, Principal};use icrc_ledger_types::icrc1::account::Account;use icrc_ledger_types::icrc1::transfer::{TransferArg, TransferError};use ic_cdk::update;use ic_cdk::call::Call;
const ICP_LEDGER: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";const ICP_FEE: u64 = 10_000;
fn ledger_id() -> Principal { Principal::from_text(ICP_LEDGER).unwrap()}
/// Transfer tokens from this canister's default account./// WARNING: Add access control in production.#[update]async fn send_tokens(to: Principal, amount: Nat) -> Result<Nat, String> { let transfer_arg = TransferArg { from_subaccount: None, to: Account { owner: to, subaccount: None }, amount, fee: Some(Nat::from(ICP_FEE)), memo: None, created_at_time: Some(ic_cdk::api::time()), };
let (result,): (Result<Nat, TransferError>,) = Call::unbounded_wait(ledger_id(), "icrc1_transfer") .with_arg(transfer_arg) .await .map_err(|e| format!("Call failed: {:?}", e))? .candid_tuple() .map_err(|e| format!("Decode failed: {:?}", e))?;
match result { Ok(block_index) => Ok(block_index), Err(TransferError::InsufficientFunds { balance }) => { Err(format!("Insufficient funds. Balance: {}", balance)) } Err(TransferError::BadFee { expected_fee }) => { Err(format!("Wrong fee. Expected: {}", expected_fee)) } Err(e) => Err(format!("Transfer error: {:?}", e)), }}For frontend token operations, use the @icp-sdk/canisters package. See the JS SDK documentation for setup and usage.
Fee handling
Always set the fee field explicitly. If you pass a fee that does not match the ledger’s current fee, the call returns a BadFee error with the expected_fee value. You can query the current fee at runtime:
icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_fee '()' -n icTransaction deduplication
When created_at_time is set to the current nanosecond timestamp, the ledger tracks submitted transactions and rejects exact duplicates within a 24-hour window. A duplicate submission returns Duplicate { duplicate_of: block_index } instead of executing again. The duplicate_of value is the block index of the original accepted transaction, so you can confirm it succeeded without re-submitting.
Without created_at_time (set to null), every submission is treated as a new transaction: submitting the same call twice sends the amount twice.
Set created_at_time to the current nanosecond timestamp to enable deduplication:
- Motoko:
Nat64.fromNat(Int.abs(Time.now()))(as shown insendTokensabove) - Rust:
ic_cdk::api::time()(as shown insend_tokensabove)
Two boundary errors to handle alongside the normal transfer errors:
TooOld: the timestamp is more than 24 hours in the past. The ledger no longer tracks that window and rejects the transaction.CreatedInFuture { ledger_time }: the timestamp is ahead of the ledger’s current time, typically due to system clock drift. Theledger_timefield shows the ledger’s view of the current time so you can diagnose the skew.
Always set created_at_time in production canister code. null is only appropriate for one-off manual CLI calls where double-submission is not a concern.
Checking balances
Query an account’s balance with icrc1_balance_of. This is a query call: fast and free.
icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_balance_of \ '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ -n icReplace ryjl3-tyaaa-aaaaa-aaaba-cai with the ledger canister ID for any ICRC-1 compatible asset.
persistent actor {
type Account = { owner : Principal; subaccount : ?Blob };
transient let icpLedger = actor ("ryjl3-tyaaa-aaaaa-aaaba-cai") : actor { icrc1_balance_of : shared query (Account) -> async Nat; };
public func getBalance(owner : Principal) : async Nat { await icpLedger.icrc1_balance_of({ owner = owner; subaccount = null }) };}use candid::{Nat, Principal};use icrc_ledger_types::icrc1::account::Account;use ic_cdk::call::Call;
async fn get_balance(ledger: Principal, owner: Principal) -> Result<Nat, String> { let account = Account { owner, subaccount: None };
let (balance,): (Nat,) = Call::unbounded_wait(ledger, "icrc1_balance_of") .with_arg(account) .await .map_err(|e| format!("Call failed: {:?}", e))? .candid_tuple() .map_err(|e| format!("Decode failed: {:?}", e))?;
Ok(balance)}Approve and transfer-from (ICRC-2)
ICRC-2 adds an approve/transferFrom pattern. The asset owner first approves a spender for a certain amount, then the spender calls icrc2_transfer_from to move assets. This is a two-step flow: calling transfer_from without a prior approval fails with InsufficientAllowance.
When to use: Exchange logic, payment processing, subscription services, or any case where a canister needs to pull assets from a user’s account.
import Nat "mo:core/Nat";import Nat64 "mo:core/Nat64";import Int "mo:core/Int";import Time "mo:core/Time";import Runtime "mo:core/Runtime";
persistent actor {
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 TransferFromArg = { spender_subaccount : ?Blob; from : Account; to : Account; amount : Nat; fee : ?Nat; memo : ?Blob; created_at_time : ?Nat64; };
type TransferFromError = { #BadFee : { expected_fee : Nat }; #BadBurn : { min_burn_amount : Nat }; #InsufficientFunds : { balance : Nat }; #InsufficientAllowance : { allowance : Nat }; #TooOld; #CreatedInFuture : { ledger_time : Nat64 }; #Duplicate : { duplicate_of : Nat }; #TemporarilyUnavailable; #GenericError : { error_code : Nat; message : Text }; };
transient let icpLedger = actor ("ryjl3-tyaaa-aaaaa-aaaba-cai") : actor { icrc2_approve : shared (ApproveArg) -> async { #Ok : Nat; #Err : ApproveError }; icrc2_transfer_from : shared (TransferFromArg) -> async { #Ok : Nat; #Err : TransferFromError }; };
public func approveSpender(spender : Principal, amount : Nat) : async Nat { let now = Nat64.fromNat(Int.abs(Time.now())); let result = await icpLedger.icrc2_approve({ from_subaccount = null; spender = { owner = spender; subaccount = null }; amount = amount; expected_allowance = null; expires_at = null; fee = ?10_000; memo = null; created_at_time = ?now; }); switch (result) { case (#Ok(blockIndex)) { blockIndex }; case (#Err(_)) { Runtime.trap("Approve failed") }; } };
/// WARNING: Add access control in production. public func transferFrom(from : Principal, to : Principal, amount : Nat) : async Nat { let now = Nat64.fromNat(Int.abs(Time.now())); let result = await icpLedger.icrc2_transfer_from({ spender_subaccount = null; from = { owner = from; subaccount = null }; to = { owner = to; subaccount = null }; amount = amount; fee = ?10_000; memo = null; created_at_time = ?now; }); switch (result) { case (#Ok(blockIndex)) { blockIndex }; case (#Err(#InsufficientAllowance({ allowance }))) { Runtime.trap("Insufficient allowance: " # Nat.toText(allowance)) }; case (#Err(_)) { Runtime.trap("TransferFrom failed") }; } };}use candid::{Nat, Principal};use icrc_ledger_types::icrc1::account::Account;use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError};use icrc_ledger_types::icrc2::transfer_from::{TransferFromArgs, TransferFromError};use ic_cdk::update;use ic_cdk::call::Call;
const ICP_LEDGER: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";const ICP_FEE: u64 = 10_000;
fn ledger_id() -> Principal { Principal::from_text(ICP_LEDGER).unwrap()}
#[update]async fn approve_spender(spender: Principal, amount: Nat) -> Result<Nat, String> { let args = ApproveArgs { from_subaccount: None, spender: Account { owner: spender, subaccount: None }, amount, expected_allowance: None, expires_at: None, fee: Some(Nat::from(ICP_FEE)), memo: None, created_at_time: Some(ic_cdk::api::time()), };
let (result,): (Result<Nat, ApproveError>,) = Call::unbounded_wait(ledger_id(), "icrc2_approve") .with_arg(args) .await .map_err(|e| format!("Call failed: {:?}", e))? .candid_tuple() .map_err(|e| format!("Decode failed: {:?}", e))?;
result.map_err(|e| format!("Approve error: {:?}", e))}
/// WARNING: Add access control in production.#[update]async fn transfer_from( from: Principal, to: Principal, amount: Nat,) -> Result<Nat, String> { let args = TransferFromArgs { spender_subaccount: None, from: Account { owner: from, subaccount: None }, to: Account { owner: to, subaccount: None }, amount, fee: Some(Nat::from(ICP_FEE)), memo: None, created_at_time: Some(ic_cdk::api::time()), };
let (result,): (Result<Nat, TransferFromError>,) = Call::unbounded_wait(ledger_id(), "icrc2_transfer_from") .with_arg(args) .await .map_err(|e| format!("Call failed: {:?}", e))? .candid_tuple() .map_err(|e| format!("Decode failed: {:?}", e))?;
result.map_err(|e| format!("TransferFrom error: {:?}", e))}Working with subaccounts
An ICRC-1 account is a principal plus an optional 32-byte subaccount. Subaccounts let a single canister manage many logical accounts: useful for deposit flows where each user gets a unique deposit address.
To derive a subaccount from a principal (a common pattern for deposit accounts):
import Principal "mo:core/Principal";import Blob "mo:core/Blob";import Array "mo:core/Array";import Nat8 "mo:core/Nat8";
type Account = { owner : Principal; subaccount : ?Blob };
/// Derive a deposit subaccount from a user's principal.func depositAccount(canister : Principal, user : Principal) : Account { let bytes = Blob.toArray(Principal.toBlob(user)); let subaccount = Array.tabulate<Nat8>(32, func(i) { if (i == 0) { Nat8.fromNat(bytes.size()) } else if (i <= bytes.size()) { bytes[i - 1] } else { 0 } }); { owner = canister; subaccount = ?Blob.fromArray(subaccount) }};use candid::Principal;use icrc_ledger_types::icrc1::account::Account;
/// Derive a deposit subaccount from a user's principal./// Pads the principal bytes into a 32-byte array.fn deposit_account(canister: Principal, user: Principal) -> Account { let mut subaccount = [0u8; 32]; let principal_bytes = user.as_slice(); subaccount[0] = principal_bytes.len() as u8; subaccount[1..1 + principal_bytes.len()].copy_from_slice(principal_bytes); Account { owner: canister, subaccount: Some(subaccount), }}A typical deposit flow:
- Generate a unique deposit subaccount for each user (derived from their principal).
- The user transfers assets to your canister’s subaccount address.
- Your canister checks the subaccount balance and credits the user internally.
- Your canister sweeps assets from the subaccount to its default account.
Transaction history
Each ledger is paired with an index canister that syncs blocks continuously from the ledger and builds a per-account index. This is the standard way for canisters to query transaction history without scanning the full block log.
Query an account’s transaction history using get_account_transactions on the index canister:
icp canister call qhbym-qaaaa-aaaaa-aaafq-cai get_account_transactions \ '(record { account = record { owner = principal "YOUR-PRINCIPAL" }; max_results = 10 : nat })' \ -n icThe response includes a transactions list and the account’s current balance at the tip. Paginate backwards using the start field (the oldest block index from the previous response).
ICRC-3 is the standard that defines how ledgers expose their block structure: the block schema, archive model, and icrc3_get_blocks method. It is why index canisters, Rosetta nodes, and custom indexers can all read the same block data in a consistent, verifiable format. All system ledgers on ICP implement ICRC-3. See ICRC-3: Transaction log for the block schema and method signatures.
For client-side indexing across multiple ICRC-1 ledgers (exchange integrations, custody platforms, analytics), use the Rosetta API. The ICRC Rosetta implementation supports querying balances and transaction history across any number of ICRC-1 compatible ledgers simultaneously.
Local test ledger
To test token operations locally, deploy an ICRC-1 ledger on your local replica. First, find the latest release tag from the ledger-suite-icrc releases, then add the ledger to your icp.yaml:
canisters: - name: icrc1_ledger build: steps: - type: pre-built url: "https://github.com/dfinity/ic/releases/download/<RELEASE_TAG>/ic-icrc1-ledger.wasm.gz" init_args: path: icrc1_ledger_init.argsCreate icrc1_ledger_init.args with your principal. Replace YOUR_PRINCIPAL with the output of icp identity principal:
Shell substitutions like
$(icp identity principal)do not expand inside argument files. Paste the literal principal string.
(variant { Init = record { token_symbol = "TEST"; token_name = "Test Token"; minting_account = record { owner = principal "YOUR_PRINCIPAL" }; transfer_fee = 10_000 : nat; metadata = vec {}; initial_balances = vec { record { record { owner = principal "YOUR_PRINCIPAL" }; 100_000_000_000 : nat; }; }; archive_options = record { num_blocks_to_archive = 1000 : nat64; trigger_threshold = 2000 : nat64; controller_id = principal "YOUR_PRINCIPAL"; }; feature_flags = opt record { icrc2 = true };}})Deploy and verify:
icp network start -dicp deploy icrc1_ledgericp canister call icrc1_ledger icrc1_symbol '()'# Expected: ("TEST")Test a transfer:
icp identity new test-recipient --storage plaintext 2>/dev/nullRECIPIENT=$(icp identity principal --identity test-recipient)
icp canister call icrc1_ledger icrc1_transfer \ "(record { to = record { owner = principal \"$RECIPIENT\"; subaccount = null }; amount = 1_000_000 : nat; fee = opt (10_000 : nat); memo = null; from_subaccount = null; created_at_time = null; })"# Expected: (variant { Ok = 0 : nat })Next steps
- Digital Asset Standards: ICRC-1, ICRC-2, ICRC-3, ICRC-7, and ICRC-37 specifications including NFT standards
- Chain-Key Token Canister IDs: mainnet and testnet canister IDs for all chain-key tokens
- Chain-key tokens: minting, depositing, and withdrawing ckBTC, ckETH, ckDOGE, and ckSOL
- Rosetta API: client-side indexing and transaction construction for exchanges and custody platforms
- Wallet integration: connecting wallets to your app
- Inter-canister calls: how canister-to-canister calls work, for example when the index canister reads blocks from the ledger