﻿# Ledgers

> For the complete documentation index, see [llms.txt](/llms.txt)

Digital assets on ICP are managed by **ledger canisters** that implement the [ICRC digital asset standards](../../references/digital-asset-standards.md). 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_fee` and `icrc1_decimals` at runtime rather than hardcoding values.

For chain-key token canister IDs (ckBTC, ckETH, ckDOGE, ckSOL, and ckERC20), see [Chain-Key Token Canister IDs](../../references/chain-key-canister-ids.md). For chain-key token specifics (minting, deposits, withdrawals), see [Chain-key tokens](chain-key-tokens.md).

## 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:

```candid
{ owner: Principal; subaccount: ?Blob }  // 32-byte subaccount, null = default
```

### Motoko

```motoko
import 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") };
    }
  };
}
```

### Rust

Add these dependencies to `Cargo.toml`:

```toml
[dependencies]
ic-cdk = "0.19"
candid = "0.10"
icrc-ledger-types = "0.1"
```

```rust
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)),
    }
}
```

### JavaScript

For frontend token operations, use the `@icp-sdk/canisters` package. See the [JS SDK documentation](https://js.icp.build) 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:

```bash
icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_fee '()' -n ic
```

### Transaction 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 in `sendTokens` above)
- **Rust**: `ic_cdk::api::time()` (as shown in `send_tokens` above)

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. The `ledger_time` field 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.

```bash
icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_balance_of \
  '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \
  -n ic
```

Replace `ryjl3-tyaaa-aaaaa-aaaba-cai` with the ledger canister ID for any ICRC-1 compatible asset.

### Motoko

```motoko
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 })
  };
}
```

### Rust

```rust
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.

### Motoko

```motoko
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") };
    }
  };
}
```

### Rust

```rust
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):

### Motoko

```motoko
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) }
};
```

### Rust

```rust
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:

1. Generate a unique deposit subaccount for each user (derived from their principal).
2. The user transfers assets to your canister's subaccount address.
3. Your canister checks the subaccount balance and credits the user internally.
4. 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:

```bash
icp canister call qhbym-qaaaa-aaaaa-aaafq-cai get_account_transactions \
  '(record { account = record { owner = principal "YOUR-PRINCIPAL" }; max_results = 10 : nat })' \
  -n ic
```

The 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](../../references/digital-asset-standards.md#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](rosetta.md). 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](https://github.com/dfinity/ic/releases?q=%22ledger-suite-icrc%22&expanded=false), then add the ledger to your `icp.yaml`:

```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.args
```

Create `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:

```bash
icp network start -d
icp deploy icrc1_ledger
icp canister call icrc1_ledger icrc1_symbol '()'
# Expected: ("TEST")
```

Test a transfer:

```bash
icp identity new test-recipient --storage plaintext 2>/dev/null
RECIPIENT=$(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](../../references/digital-asset-standards.md): ICRC-1, ICRC-2, ICRC-3, ICRC-7, and ICRC-37 specifications including NFT standards
- [Chain-Key Token Canister IDs](../../references/chain-key-canister-ids.md): mainnet and testnet canister IDs for all chain-key tokens
- [Chain-key tokens](chain-key-tokens.md): minting, depositing, and withdrawing ckBTC, ckETH, ckDOGE, and ckSOL
- [Rosetta API](rosetta.md): client-side indexing and transaction construction for exchanges and custody platforms
- [Wallet integration](wallet-integration.md): connecting wallets to your app
- [Inter-canister calls](../canister-calls/inter-canister-calls.md#making-calls): how canister-to-canister calls work, for example when the index canister reads blocks from the ledger
