icrc-ledger

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

ICRC Ledger Standards

ICRC账本标准

What This Is

概述

ICRC-1 is the fungible token standard on Internet Computer, defining transfer, balance, and metadata interfaces. ICRC-2 extends it with approve/transferFrom (allowance) mechanics, enabling third-party spending like ERC-20 on Ethereum.
ICRC-1是Internet Computer上的 fungible token 标准,定义了转账、余额和元数据接口。ICRC-2对其进行了扩展,增加了approve/transferFrom(授权)机制,支持类似以太坊ERC-20的第三方代付功能。

Prerequisites

前置条件

  • For Motoko: mops with
    core = "2.0.0"
    in mops.toml
  • For Rust:
    ic-cdk = "0.19"
    ,
    candid = "0.10"
    ,
    icrc-ledger-types = "0.1"
    in Cargo.toml
  • 对于Motoko:mops工具,且mops.toml中配置
    core = "2.0.0"
  • 对于Rust:Cargo.toml中配置
    ic-cdk = "0.19"
    candid = "0.10"
    icrc-ledger-types = "0.1"

Canister IDs

容器ID

TokenLedger Canister IDFeeDecimals
ICP
ryjl3-tyaaa-aaaaa-aaaba-cai
10000 e8s (0.0001 ICP)8
ckBTC
mxzaz-hqaaa-aaaar-qaada-cai
10 satoshis8
ckETH
ss2fx-dyaaa-aaaar-qacoq-cai
2000000000000 wei (0.000002 ETH)18
Index canisters (for transaction history):
  • ICP Index:
    qhbym-qaaaa-aaaaa-aaafq-cai
  • ckBTC Index:
    n5wcd-faaaa-aaaar-qaaea-cai
  • ckETH Index:
    s3zol-vqaaa-aaaar-qacpa-cai
代币账本容器ID手续费小数位数
ICP
ryjl3-tyaaa-aaaaa-aaaba-cai
10000 e8s(0.0001 ICP)8
ckBTC
mxzaz-hqaaa-aaaar-qaada-cai
10 satoshis8
ckETH
ss2fx-dyaaa-aaaar-qacoq-cai
2000000000000 wei(0.000002 ETH)18
索引容器(用于交易历史):
  • ICP索引:
    qhbym-qaaaa-aaaaa-aaafq-cai
  • ckBTC索引:
    n5wcd-faaaa-aaaar-qaaea-cai
  • ckETH索引:
    s3zol-vqaaa-aaaar-qacpa-cai

Mistakes That Break Your Build

导致构建失败的常见错误

  1. Wrong fee amount -- ICP fee is 10000 e8s, NOT 10000 ICP. ckBTC fee is 10 satoshis, NOT 10 ckBTC. Using the wrong unit drains your entire balance in one transfer.
  2. Forgetting approve before transferFrom -- ICRC-2 transferFrom will reject with
    InsufficientAllowance
    if the token owner has not called
    icrc2_approve
    first. This is a two-step flow: owner approves, then spender calls transferFrom.
  3. Not handling Err variants --
    icrc1_transfer
    returns
    Result<Nat, TransferError>
    , not just
    Nat
    . The error variants are:
    BadFee
    ,
    BadBurn
    ,
    InsufficientFunds
    ,
    TooOld
    ,
    CreatedInFuture
    ,
    Duplicate
    ,
    TemporarilyUnavailable
    ,
    GenericError
    . You must match on every variant or at minimum propagate the error.
  4. Using wrong Account format -- An ICRC-1 Account is
    { owner: Principal; subaccount: ?Blob }
    , NOT just a Principal. The subaccount is a 32-byte blob. Passing null/None for subaccount uses the default subaccount (all zeros).
  5. Omitting created_at_time -- Without
    created_at_time
    , you lose deduplication protection. Two identical transfers submitted within 24h will both execute. Set
    created_at_time
    to
    Time.now()
    (Motoko) or
    ic_cdk::api::time()
    (Rust) for dedup.
  6. Hardcoding canister IDs as text -- Always use
    Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai")
    (Motoko) or
    Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai")
    (Rust). Never pass raw strings where a Principal is expected.
  7. Calling ledger from frontend -- ICRC-1 transfers should originate from a backend canister, not directly from the frontend. Frontend-initiated transfers expose the user to reentrancy and can bypass business logic. Use a backend canister as the intermediary.
  8. Shell substitution in
    --argument-file
    /
    init_arg_file
    -- Expressions like
    $(icp identity principal)
    do NOT expand inside files referenced by
    init_arg_file
    or
    --argument-file
    . The file is read as literal text. Either use
    --argument
    on the command line (where the shell expands variables), or pre-generate the file with
    envsubst
    /
    sed
    before deploying.
  1. 手续费金额错误 -- ICP的手续费是10000 e8s,而非10000 ICP。ckBTC的手续费是10 satoshis,而非10 ckBTC。使用错误单位会导致一次转账耗尽全部余额。
  2. 调用transferFrom前未执行approve -- 如果代币所有者未先调用
    icrc2_approve
    ,ICRC-2的transferFrom会返回
    InsufficientAllowance
    错误。这是一个两步流程:所有者先授权,然后代付方调用transferFrom。
  3. 未处理错误变体 --
    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. 将容器ID硬编码为文本 -- 请始终使用
    Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai")
    (Motoko)或
    Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai")
    (Rust)。切勿在需要传入Principal的位置直接传入原始字符串。
  7. 从前端直接调用账本 -- ICRC-1转账应从后端容器发起,而非直接从前端发起。前端发起的转账会让用户面临重入风险,且可能绕过业务逻辑。请使用后端容器作为中间层。
  8. --argument-file
    /
    init_arg_file
    中使用Shell替换
    -- 类似
    $(icp identity principal)
    的表达式在
    init_arg_file
    --argument-file
    引用的文件中不会被展开。文件内容会被当作字面量读取。请在命令行中使用
    --argument
    参数(Shell会在此处展开变量),或在部署前使用
    envsubst
    /
    sed
    预先生成文件。

Implementation

代码实现

Motoko

Motoko

Imports and Types

导入与类型定义

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

Define the ICRC-1 Actor Interface

定义ICRC-1 Actor接口

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

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

  // 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 = ?10000; // ICP fee: 10000 e8s
      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 = ?10000;
      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 = ?10000;
      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") };
    }
  };
}
motoko
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 };
  };

  // 远程账本Actor引用(示例为ICP账本;如需操作其他代币,替换容器ID即可)
  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;
  };

  // 查询余额
  public func getBalance(who : Principal) : async Nat {
    await icpLedger.icrc1_balance_of({
      owner = who;
      subaccount = null;
    })
  };

  // 转账代币(当前容器从自身账户发起转账)
  // 注意:生产环境中请添加访问控制——当前代码允许任何调用者发起转账
  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 = ?10000; // ICP手续费:10000 e8s
      memo = null;
      created_at_time = ?now;
    });
    switch (result) {
      case (#Ok(blockIndex)) { blockIndex };
      case (#Err(#InsufficientFunds({ balance }))) {
        Runtime.trap("余额不足。当前余额:" # Nat.toText(balance))
      };
      case (#Err(#BadFee({ expected_fee }))) {
        Runtime.trap("手续费错误。预期手续费:" # Nat.toText(expected_fee))
      };
      case (#Err(_)) { Runtime.trap("转账失败") };
    }
  };

  // ICRC-2:授权代付方
  public shared ({ caller }) func approveSpender(spender : Principal, amount : Nat) : async Nat {
    // Motoko会在函数入口捕获caller——跨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 = ?10000;
      memo = null;
      created_at_time = ?now;
    });
    switch (result) {
      case (#Ok(blockIndex)) { blockIndex };
      case (#Err(_)) { Runtime.trap("授权失败") };
    }
  };

  // ICRC-2:从其他账户转账(需提前完成授权)
  // 注意:生产环境中请添加访问控制——当前代码允许任何调用者发起转账
  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 = ?10000;
      memo = null;
      created_at_time = ?now;
    });
    switch (result) {
      case (#Ok(blockIndex)) { blockIndex };
      case (#Err(#InsufficientAllowance({ allowance }))) {
        Runtime.trap("授权额度不足:" # Nat.toText(allowance))
      };
      case (#Err(_)) { Runtime.trap("代付转账失败") };
    }
  };
}

Rust

Rust

Cargo.toml Dependencies

Cargo.toml依赖配置

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"] }
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"] }

Complete Implementation

完整实现代码

rust
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";
const ICP_FEE: u64 = 10_000; // 10000 e8s

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

// ICRC-2: Approve a spender
#[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))
}

// 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 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))
}
rust
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";
const ICP_FEE: u64 = 10_000; // 10000 e8s

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

// 查询余额
#[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("调用icrc1_balance_of失败")
        .candid_tuple()
        .expect("解码响应失败");
    balance
}

// 从当前容器账户转账代币
// 注意:生产环境中请添加访问控制——当前代码允许任何调用者发起转账
#[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!("调用失败:{:?}", e))?
        .candid_tuple()
        .map_err(|e| format!("解码失败:{:?}", e))?;

    match result {
        Ok(block_index) => Ok(block_index),
        Err(TransferError::InsufficientFunds { balance }) => {
            Err(format!("余额不足。当前余额:{}", balance))
        }
        Err(TransferError::BadFee { expected_fee }) => {
            Err(format!("手续费错误。预期手续费:{}", expected_fee))
        }
        Err(e) => Err(format!("转账错误:{:?}", e)),
    }
}

// ICRC-2:授权代付方
#[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!("调用失败:{:?}", e))?
        .candid_tuple()
        .map_err(|e| format!("解码失败:{:?}", e))?;

    result.map_err(|e| format!("授权错误:{:?}", e))
}

// ICRC-2:从其他账户转账(需提前完成授权)
// 注意:生产环境中请添加访问控制——当前代码允许任何调用者发起转账
#[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!("调用失败:{:?}", e))?
        .candid_tuple()
        .map_err(|e| format!("解码失败:{:?}", e))?;

    result.map_err(|e| format!("代付转账错误:{:?}", e))
}

Deploy & Test

部署与测试

Deploy a Local ICRC-1 Ledger for Testing

部署本地ICRC-1测试账本

Add to
icp.yaml
:
Pin the release version before deploying: get the latest release tag from https://github.com/dfinity/ic/releases?q=%22ledger-suite-icrc%22&expanded=false, then substitute it for
<RELEASE_TAG>
in both URLs below.
yaml
canisters:
  icrc1_ledger:
    name: icrc1_ledger
    recipe:
      type: custom
      candid: "https://github.com/dfinity/ic/releases/download/<RELEASE_TAG>/ledger.did"
      wasm: "https://github.com/dfinity/ic/releases/download/<RELEASE_TAG>/ic-icrc1-ledger.wasm.gz"
    config:
      init_arg_file: "icrc1_ledger_init.args"
Create
icrc1_ledger_init.args
(replace
YOUR_PRINCIPAL
with the output of
icp identity principal
):
Pitfall: Shell substitutions like
$(icp identity principal)
will NOT expand inside this file. You must 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:
bash
undefined
icp.yaml
中添加以下配置:
yaml
canisters:
  icrc1_ledger:
    name: icrc1_ledger
    recipe:
      type: custom
      candid: "https://github.com/dfinity/ic/releases/download/<RELEASE_TAG>/ledger.did"
      wasm: "https://github.com/dfinity/ic/releases/download/<RELEASE_TAG>/ic-icrc1-ledger.wasm.gz"
    config:
      init_arg_file: "icrc1_ledger_init.args"
创建
icrc1_ledger_init.args
文件(将
YOUR_PRINCIPAL
替换为
icp identity principal
命令的输出):
注意:类似
$(icp identity principal)
的Shell替换表达式在此文件中不会被展开。你必须粘贴字面量的principal字符串。
(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 };
}})
执行部署:
bash
undefined

Start local replica

启动本地节点

icp network start -d
icp network start -d

Deploy the ledger

部署账本

icp deploy icrc1_ledger
icp deploy icrc1_ledger

Verify it deployed

验证部署结果

icp canister id icrc1_ledger
undefined
icp canister id icrc1_ledger
undefined

Interact with Mainnet Ledgers

与主网账本交互

bash
undefined
bash
undefined

Check ICP balance

查询ICP余额

icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_balance_of
"(record { owner = principal "$(icp identity principal)"; subaccount = null })"
-e ic
icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_balance_of
"(record { owner = principal "$(icp identity principal)"; subaccount = null })"
-e ic

Check token metadata

查询代币元数据

icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_metadata '()' -e ic
icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_metadata '()' -e ic

Check fee

查询手续费

icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_fee '()' -e ic
icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_fee '()' -e ic

Transfer ICP (amount in e8s: 100000000 = 1 ICP)

转账ICP(金额单位为e8s:100000000 = 1 ICP)

icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_transfer
"(record { to = record { owner = principal "TARGET_PRINCIPAL_HERE"; subaccount = null }; amount = 100_000_000 : nat; fee = opt (10_000 : nat); memo = null; from_subaccount = null; created_at_time = null; })" -e ic
undefined
icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_transfer
"(record { to = record { owner = principal "TARGET_PRINCIPAL_HERE"; subaccount = null }; amount = 100_000_000 : nat; fee = opt (10_000 : nat); memo = null; from_subaccount = null; created_at_time = null; })" -e ic
undefined

Verify It Works

验证功能

Local Ledger Verification

本地账本验证

bash
undefined
bash
undefined

1. Check your balance (should show initial minted amount)

1. 查询自身余额(应显示初始铸造的金额)

icp canister call icrc1_ledger icrc1_balance_of
"(record { owner = principal "$(icp identity principal)"; subaccount = null })"
icp canister call icrc1_ledger icrc1_balance_of
"(record { owner = principal "$(icp identity principal)"; subaccount = null })"

Expected: (100_000_000_000 : nat)

预期结果:(100_000_000_000 : nat)

2. Check fee

2. 查询手续费

icp canister call icrc1_ledger icrc1_fee '()'
icp canister call icrc1_ledger icrc1_fee '()'

Expected: (10_000 : nat)

预期结果:(10_000 : nat)

3. Check decimals

3. 查询小数位数

icp canister call icrc1_ledger icrc1_decimals '()'
icp canister call icrc1_ledger icrc1_decimals '()'

Expected: (8 : nat8)

预期结果:(8 : nat8)

4. Check symbol

4. 查询代币符号

icp canister call icrc1_ledger icrc1_symbol '()'
icp canister call icrc1_ledger icrc1_symbol '()'

Expected: ("TEST")

预期结果:("TEST")

5. Transfer to another identity

5. 转账至另一个身份

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

预期结果:(variant { Ok = 0 : nat })

6. Verify recipient balance

6. 验证接收方余额

icp canister call icrc1_ledger icrc1_balance_of
"(record { owner = principal "$RECIPIENT"; subaccount = null })"
icp canister call icrc1_ledger icrc1_balance_of
"(record { owner = principal "$RECIPIENT"; subaccount = null })"

Expected: (1_000_000 : nat)

预期结果:(1_000_000 : nat)

undefined
undefined

Mainnet Verification

主网验证

bash
undefined
bash
undefined

Verify ICP ledger is reachable

验证ICP账本可达

icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_symbol '()' -e ic
icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_symbol '()' -e ic

Expected: ("ICP")

预期结果:("ICP")

Verify ckBTC ledger is reachable

验证ckBTC账本可达

icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_symbol '()' -e ic
icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_symbol '()' -e ic

Expected: ("ckBTC")

预期结果:("ckBTC")

Verify ckETH ledger is reachable

验证ckETH账本可达

icp canister call ss2fx-dyaaa-aaaar-qacoq-cai icrc1_symbol '()' -e ic
icp canister call ss2fx-dyaaa-aaaar-qacoq-cai icrc1_symbol '()' -e ic

Expected: ("ckETH")

预期结果:("ckETH")

undefined
undefined