关于 icrc-ledger
面向 Internet Computer 开发者的 ICRC-1 与 ICRC-2 账本交互技能,提供 Motoko 和 Rust 实现指南,涵盖转账、授权、账本配置及常见错误处理。
主要功能
- 01ICRC-1 余额与转账接口
- 02ICRC-2 授权和 transferFrom 流程
- 03Motoko 和 Rust 实现指南
- 04账本配置与部署步骤
- 05费用、账户和错误处理
适用场景
- 01在 Canister 中实现同质化代币转账
- 02通过 ICRC-2 授权添加委托支出
- 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 | 小数位数 |
|---|---|---|
| ICP | ryjl3-tyaaa-aaaaa-aaaba-cai | 8 |
| ckBTC | mxzaz-hqaaa-aaaar-qaada-cai | 8 |
| ckETH | ss2fx-dyaaa-aaaar-qacoq-cai | 18 |
索引 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)。每个账本自行设定手续费,且手续费可在运行时变更。
常见陷阱
-
假设所有账本的手续费都相同——每个账本都有自己的手续费(例如,ICP = 10000 e8s,ckBTC = 10 satoshis)。切勿从一个账本复制手续费数值并用于另一个账本。请通过
icrc1_fee查询手续费(链上查询,或通过 CLI 运行icp canister call <ledger> icrc1_fee '()')。手续费也可能在运行时发生变化,因此务必处理BadFee { expected_fee }——账本会在错误响应中告知正确的手续费。 -
忘记在 transferFrom 前调用 approve——如果代币所有者尚未先调用
icrc2_approve,ICRC-2 transferFrom 将返回InsufficientAllowance。这是一个两步流程:所有者先授权,然后支出方调用 transferFrom。 -
未处理 Err 变体——
icrc1_transfer返回的是Result<Nat, TransferError>,而不只是Nat。错误变体包括:BadFee、BadBurn、InsufficientFunds、TooOld、CreatedInFuture、Duplicate、TemporarilyUnavailable、GenericError。必须匹配每个变体,或至少传递错误。 -
使用错误的 Account 格式——ICRC-1 Account 的格式是
{ owner: Principal; subaccount: ?Blob },而不只是一个 Principal。subaccount 是一个 32 字节的 Blob。为 subaccount 传递 null/None 会使用默认子账户(全零)。 -
省略 created_at_time——如果不设置
created_at_time,就会失去去重保护。在 24 小时内提交的两笔相同转账都会执行。请将created_at_time设为Time.now()(Motoko)或ic_cdk::api::time()(Rust)以进行去重。 -
将 canister ID 硬编码为文本——请始终使用
Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai")(Motoko)或Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai")(Rust)。需要 Principal 时,切勿传入原始字符串。 -
从前端调用账本——ICRC-1 转账应由后端 canister 发起,而不是直接从前端发起。从前端发起转账会使用户面临重入风险,并可能绕过业务逻辑。请使用后端 canister 作为中介。
-
在
--argument-file/init_args.path中使用 Shell 替换——像$(icp identity principal)这样的表达式,在init_args: { path: ... }引用的文件或--argument-file中不会展开。文件会被当作字面文本读取。请改用命令行上的--argument(此时 Shell 会展开变量),或者在部署前使用envsubst/sed预先生成文件。 -
铸币账户无法调用
icrc2_approve——如果账本的minting_account和initial_balances使用同一个 Principal,该 Principal 就无法调用icrc2_approve——账本会因“铸币账户无法委托铸币”而触发陷阱。请务必为minting_account使用一个独立的 Principal,并为initial_balances使用其他 Principal。在生产环境中,铸币账户通常是专用的 minter canister(例如 ckBTC minter);在本地开发时,任何不同于已充值账户的 Principal 都可以。 -
往返铸币账户的转账手续费为零——转账到铸币账户属于销毁,而从铸币账户转账属于铸造。两者都要求
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