icrc-ledger について
Internet Computer で ICRC-1 と ICRC-2 の台帳連携を実装する開発者向けスキルです。Motoko と Rust による送金、承認、台帳設定、一般的なエラー処理を案内します。
主な機能
- 01ICRC-1 の残高・送金インターフェース
- 02ICRC-2 の承認・transferFrom フロー
- 03Motoko と Rust の実装ガイド
- 04台帳の設定とデプロイ手順
- 05手数料・アカウント・エラー処理
活用シーン
- 01Canister での代替可能トークン送金の実装
- 02ICRC-2 の許可を使った代理支出の追加
- 03初期残高付きテスト用 ICRC 台帳のデプロイ
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dfinity/ icskills --skill icrc-ledgerインストール後、Claude Code で/icrc-ledgerを入力して読み込まれたか確認します。
詳細
ICRC Ledger Standards
概要
ICRC-1 は Internet Computer の代替可能トークン標準であり、送金、残高、メタデータのインターフェースを定義します。ICRC-2 は approve/transferFrom(利用許可)の仕組みを追加し、Ethereum の ERC-20 のように第三者による支出を可能にします。
前提条件
- Motoko の場合:mops.toml で
core = "2.3.1"を指定した mops - Rust の場合:Cargo.toml で
ic-cdk = "0.19"、candid = "0.10"、icrc-ledger-types = "0.1"を指定
Canister ID
| トークン | Ledger Canister ID | 小数桁数 |
|---|---|---|
| ICP | ryjl3-tyaaa-aaaaa-aaaba-cai | 8 |
| ckBTC | mxzaz-hqaaa-aaaar-qaada-cai | 8 |
| ckETH | ss2fx-dyaaa-aaaar-qacoq-cai | 18 |
インデックスキャニスター(取引履歴用):
- ICP Index:
qhbym-qaaaa-aaaaa-aaafq-cai - ckBTC Index:
n5wcd-faaaa-aaaar-qaaea-cai - ckETH Index:
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を返して拒否されます。これは 2 段階の処理です。所有者が承認し、その後、使用者が transferFrom を呼び出します。 -
Err のバリアントを処理しない --
icrc1_transferが返すのはNatではなく、Result<Nat, TransferError>です。エラーのバリアントは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 時間以内に送信された同一の transfer は、どちらも実行されます。重複排除のために、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 の transfer は、フロントエンドから直接ではなく、バックエンドの canister から実行してください。フロントエンドから開始する transfer は、ユーザーを再入攻撃にさらし、ビジネスロジックを迂回する可能性があります。仲介役としてバックエンドの canister を使用してください。
-
--argument-file/init_args.path内でのシェル置換 --$(icp identity principal)のような式は、init_args: { path: ... }または--argument-fileで参照されるファイル内では展開されません。ファイルはリテラルテキストとして読み込まれます。コマンドラインで--argumentを使用する(シェルによって変数が展開されます)か、デプロイ前にenvsubst/sedでファイルを生成してください。 -
Minting account は
icrc2_approveを呼び出せない -- 台帳のminting_accountとinitial_balancesに同じ Principal を使用している場合、その Principal はicrc2_approveを呼び出せません。台帳は「the minting account cannot delegate mints」でトラップします。minting_accountには、initial_balancesで使用するものとは別の Principal を必ず指定してください。本番環境では、minting account は通常、専用の minter canister(例:ckBTC minter)です。ローカル開発では、資金を割り当てたアカウントと異なる Principal であれば使用できます。 -
Minting account との間の transfer では手数料がゼロ -- minting account 宛ての transfer はバーンであり、minting account からの transfer はミントです。どちらも
fee = null(またはfee = ?0)が必要です。通常の transfer 手数料(例: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)のようなシェルの置換は、このファイル内では展開されません。プリンシパル文字列をそのまま貼り付ける必要があります。
(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