mcpField
I
icrc-ledger

icrc-ledger

github.com/dfinity开发39272 次安装今天更新
代码仓库

关于 icrc-ledger

面向 Internet Computer 开发者的 ICRC-1 与 ICRC-2 账本交互技能,提供 Motoko 和 Rust 实现指南,涵盖转账、授权、账本配置及常见错误处理。

主要功能

  1. 01ICRC-1 余额与转账接口
  2. 02ICRC-2 授权和 transferFrom 流程
  3. 03Motoko 和 Rust 实现指南
  4. 04账本配置与部署步骤
  5. 05费用、账户和错误处理

适用场景

  1. 01在 Canister 中实现同质化代币转账
  2. 02通过 ICRC-2 授权添加委托支出
  3. 03部署带初始余额的测试 ICRC 账本

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dfinity/icskills --skill icrc-ledger

安装后在 Claude Code 中输入/icrc-ledger确认已加载。

详细介绍

ICRC 账本标准

概述

ICRC-1 是 Internet Computer 上的同质化代币标准,定义了转账、余额和元数据接口。ICRC-2 在此基础上扩展了 approve/transferFrom(授权额度)机制,支持第三方消费,类似于 Ethereum 上的 ERC-20。

前置条件

  • 对于 Motoko:在 mops.toml 中使用 core = "2.3.1"
  • 对于 Rust:在 Cargo.toml 中使用 ic-cdk = "0.19"、candid = "0.10"、icrc-ledger-types = "0.1"

Canister ID

代币账本 Canister ID小数位数
ICPryjl3-tyaaa-aaaaa-aaaba-cai8
ckBTCmxzaz-hqaaa-aaaar-qaada-cai8
ckETHss2fx-dyaaa-aaaar-qacoq-cai18

索引 Canister(用于交易历史记录):

  • ICP 索引:qhbym-qaaaa-aaaaa-aaafq-cai
  • ckBTC 索引:n5wcd-faaaa-aaaar-qaaea-cai
  • ckETH 索引:s3zol-vqaaa-aaaar-qacpa-cai

所有 ICRC-1 账本都提供 icrc1_fee : () -> (nat) query,用于返回当前转账手续费。手续费以代币的最小单位计价(ICP 使用 e8s,其中 1 ICP = 10⁸ e8s;ckBTC 使用 satoshi;ckETH 使用 wei)。每个账本自行设定手续费,且手续费可在运行时变更。

常见陷阱

  1. 假设所有账本的手续费都相同——每个账本都有自己的手续费(例如,ICP = 10000 e8s,ckBTC = 10 satoshis)。切勿从一个账本复制手续费数值并用于另一个账本。请通过 icrc1_fee 查询手续费(链上查询,或通过 CLI 运行 icp canister call <ledger> icrc1_fee '()')。手续费也可能在运行时发生变化,因此务必处理 BadFee { expected_fee }——账本会在错误响应中告知正确的手续费。

  2. 忘记在 transferFrom 前调用 approve——如果代币所有者尚未先调用 icrc2_approve,ICRC-2 transferFrom 将返回 InsufficientAllowance。这是一个两步流程:所有者先授权,然后支出方调用 transferFrom。

  3. 未处理 Err 变体——icrc1_transfer 返回的是 Result<Nat, TransferError>,而不只是 Nat。错误变体包括:BadFee、BadBurn、InsufficientFunds、TooOld、CreatedInFuture、Duplicate、TemporarilyUnavailable、GenericError。必须匹配每个变体,或至少传递错误。

  4. 使用错误的 Account 格式——ICRC-1 Account 的格式是 { owner: Principal; subaccount: ?Blob },而不只是一个 Principal。subaccount 是一个 32 字节的 Blob。为 subaccount 传递 null/None 会使用默认子账户(全零)。

  5. 省略 created_at_time——如果不设置 created_at_time,就会失去去重保护。在 24 小时内提交的两笔相同转账都会执行。请将 created_at_time 设为 Time.now()(Motoko)或 ic_cdk::api::time()(Rust)以进行去重。

  6. 将 canister ID 硬编码为文本——请始终使用 Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai")(Motoko)或 Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai")(Rust)。需要 Principal 时,切勿传入原始字符串。

  7. 从前端调用账本——ICRC-1 转账应由后端 canister 发起,而不是直接从前端发起。从前端发起转账会使用户面临重入风险,并可能绕过业务逻辑。请使用后端 canister 作为中介。

  8. 在 --argument-file / init_args.path 中使用 Shell 替换——像 $(icp identity principal) 这样的表达式,在 init_args: { path: ... } 引用的文件或 --argument-file 中不会展开。文件会被当作字面文本读取。请改用命令行上的 --argument(此时 Shell 会展开变量),或者在部署前使用 envsubst / sed 预先生成文件。

  9. 铸币账户无法调用 icrc2_approve——如果账本的 minting_account 和 initial_balances 使用同一个 Principal,该 Principal 就无法调用 icrc2_approve——账本会因“铸币账户无法委托铸币”而触发陷阱。请务必为 minting_account 使用一个独立的 Principal,并为 initial_balances 使用其他 Principal。在生产环境中,铸币账户通常是专用的 minter canister(例如 ckBTC minter);在本地开发时,任何不同于已充值账户的 Principal 都可以。

  10. 往返铸币账户的转账手续费为零——转账到铸币账户属于销毁,而从铸币账户转账属于铸造。两者都要求 fee = null(或 fee = ?0)。如果传入常规转账手续费(例如 ICP 的 fee = ?10000),就会因 BadFee { expected_fee = 0 } 而失败。错误消息不会说明涉及销毁/铸造语义——只会显示预期手续费为 0。

实现

Motoko
导入和类型
import Principal "mo:core/Principal";
import Nat "mo:core/Nat";
import Nat8 "mo:core/Nat8";
import Nat64 "mo:core/Nat64";
import Blob "mo:core/Blob";
import Time "mo:core/Time";
import Int "mo:core/Int";
import Runtime "mo:core/Runtime";
定义 ICRC-1 Actor 接口
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 };
  };

  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 };
  };

  type Value = {
    #Nat : Nat;
    #Int : Int;
    #Text : Text;
    #Blob : Blob;
  };

  // Remote ledger actor reference (ICP ledger shown; swap canister ID for other tokens)
  transient let icpLedger = actor ("ryjl3-tyaaa-aaaaa-aaaba-cai") : actor {
    icrc1_balance_of : shared query (Account) -> async Nat;
    icrc1_transfer : shared (TransferArg) -> async { #Ok : Nat; #Err : TransferError };
    icrc2_approve : shared (ApproveArg) -> async { #Ok : Nat; #Err : ApproveError };
    icrc2_transfer_from : shared (TransferFromArg) -> async { #Ok : Nat; #Err : TransferFromError };
    icrc1_fee : shared query () -> async Nat;
    icrc1_decimals : shared query () -> async Nat8;
    icrc1_metadata : shared query () -> async [(Text, Value)];
    icrc1_supported_standards : shared query () -> async [{ name : Text; url : Text }];
  };

  // Fee for the ICP ledger — look up via icrc1_fee if targeting a different ledger
  transient let icpFee : Nat = 10000;

  // Check balance
  public func getBalance(who : Principal) : async Nat {
    await icpLedger.icrc1_balance_of({
      owner = who;
      subaccount = null;
    })
  };

  // Transfer tokens (this canister sends from its own account)
  // WARNING: Add access control in production — this allows any caller to transfer tokens
  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 = ?icpFee;
      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") };
    }
  };

  // ICRC-2: Approve a spender
  public shared ({ caller }) func approveSpender(spender : Principal, amount : Nat) : async Nat {
    // caller is captured at function entry in Motoko -- safe across await
    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 = ?icpFee;
      memo = null;
      created_at_time = ?now;
    });
    switch (result) {
      case (#Ok(blockIndex)) { blockIndex };
      case (#Err(_)) { Runtime.trap("Approve failed") };
    }
  };

  // ICRC-2: Transfer from another account (requires prior approval)
  // WARNING: Add access control in production — this allows any caller to transfer tokens
  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 = ?icpFee;
      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
Cargo.toml 依赖项
[package]
name = "icrc_ledger_backend"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
ic-cdk = "0.19"
candid = "0.10"
icrc-ledger-types = "0.1"
serde = { version = "1", features = ["derive"] }
完整实现
use candid::{Nat, Principal};
use icrc_ledger_types::icrc1::account::Account;
use icrc_ledger_types::icrc1::transfer::{TransferArg, TransferError};
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";
// Fee for the ICP ledger — look up via icrc1_fee if targeting a different ledger
const ICP_FEE: u64 = 10_000;

fn ledger_id() -> Principal {
    Principal::from_text(ICP_LEDGER).unwrap()
}

// Check balance
#[update]
async fn get_balance(who: Principal) -> Nat {
    let account = Account {
        owner: who,
        subaccount: None,
    };
    let (balance,): (Nat,) = Call::unbounded_wait(ledger_id(), "icrc1_balance_of")
        .with_arg(account)
        .await
        .expect("Failed to call icrc1_balance_of")
        .candid_tuple()
        .expect("Failed to decode response");
    balance
}

// Transfer tokens from this canister's account
// WARNING: Add access control in production — this allows any caller to transfer tokens
#[update]
async fn send_tokens(to: Principal, amount: Nat) -> Result<Nat, String> {
    let fee = Nat::from(ICP_FEE);
    let transfer_arg = TransferArg {
        from_subaccount: None,
        to: Account {
            owner: to,
            subaccount: None,
        },
        amount,
        fee: Some(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)),
    }
}

// ICRC-2: Approve a spender
#[update]
async fn approve_spender(spender: Principal, amount: Nat) -> Result<Nat, String> {
    let fee = Nat::from(ICP_FEE);
    let args = ApproveArgs {
        from_subaccount: None,
        spender: Account {
            owner: spender,
            subaccount: None,
        },
        amount,
        expected_allowance: None,
        expires_at: None,
        fee: Some(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))
}

// ICRC-2: Transfer from another account (requires prior approval)
// WARNING: Add access control in production — this allows any caller to transfer tokens
#[update]
async fn transfer_from(from: Principal, to: Principal, amount: Nat) -> Result<Nat, String> {
    let fee = Nat::from(ICP_FEE);
    let args = TransferFromArgs {
        spender_subaccount: None,
        from: Account {
            owner: from,
            subaccount: None,
        },
        to: Account {
            owner: to,
            subaccount: None,
        },
        amount,
        fee: Some(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))
}

部署

添加到 icp.yaml:

部署前固定发布版本:从 https://github.com/dfinity/ic/releases?q=%22ledger-suite-icrc%22&expanded=false 获取最新的发布标签,然后在下方的 URL 中用该标签替换 <RELEASE_TAG>。

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
      format: candid

创建 icrc1_ledger_init.args,将 YOUR_PRINCIPAL 替换为 icp identity principal 的输出。

minting_account 必须与 initial_balances 中的任何主体不同(参见问题 #9)。initial_balances 支持多个条目,可在创世时为多个账户注资。

问题: 类似 $(icp identity principal) 的 Shell 替换不会在此文件中展开。你必须粘贴主体字符串的字面值。

(variant { Init = record {
  token_symbol = "TEST";
  token_name = "Test Token";
  minting_account = record { owner = principal "MINTER_PRINCIPAL" };
  transfer_fee = 10_000 : nat;
  metadata = vec {};
  initial_balances = vec {
    record {
      record { owner = principal "PRINCIPAL_A" };
      100_000_000_000 : nat;
    };
    record {
      record { owner = principal "PRINCIPAL_B" };
      50_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 };
}})

部署:

icp network start -d
icp deploy icrc1_ledger

评论

支持 Markdown