Warning: While this module has been running in production for over a year, it has not been audited and you should use it at your own risk. If you would like to contribute to getting the library audited, please send an email to austin at panindustrial dot com.
mops add icrc4-moimport ICRC4 "mo:icrc4-mo/ICRC4";Note: The see the section below on mixin patterns for modern, simple initialization.
This ICRC4 class uses a migration pattern as laid out in https://github.com/ZhenyaUsenko/motoko-migrations, but encapsulates the pattern in the Class+ pattern as described at https://forum.dfinity.org/t/writing-motoko-stable-libraries/21201. As a result, when you instantiate the class you need to pass the stable memory state into the class:
stable var icrc4_migration_state = ICRC4.init(ICRC4.initialState(), #v0_1_0(#id), _args, init_msg.caller);
let #v0_1_0(#data(icrc4_state_current)) = icrc4_migration_state;
private var _icrc4 : ?ICRC4.ICRC4 = null;
private func get_icrc4_environment() : ICRC4.Environment {
{
icrc1 = icrc1();
get_fee = null;
};
};
func icrc4() : ICRC4.ICRC4 {
switch(_icrc4){
case(null){
let initclass : ICRC4.ICRC4 = ICRC4.ICRC4(?icrc4_migration_state, Principal.fromActor(this), get_icrc4_environment());
_icrc4 := ?initclass;
initclass;
};
case(?val) val;
};
};The above pattern will allow your class to call icrc4().XXXXX to easily access the stable state of your class and you will not have to worry about pre or post upgrade methods.
/// Fee defines the structure of how fees are calculated and charged.
public type Fee = {
/// A fixed fee amount that is applied to transactions.
#Fixed: Nat;
/// Indicates fee structure is defined in the surrounding environment (see get_fee function).
#Environment;
/// Fee is defined and managed by ICRC-1 standards.
#ICRC1;
};
public type InitArgs = {
max_balances : ?Nat; // Maximum accounts in balance_of_batch query (default: 3000)
max_transfers : ?Nat; // Maximum transfers in transfer_batch call (default: 3000)
fee : ?Fee; // How to charge for fees (default: #ICRC1)
};The environment pattern lets you pass dynamic information about your environment to the class.
// Environment defines the context in which the token ledger operates.
public type Environment = {
/// Reference to the ICRC-1 ledger interface.
icrc1 : ICRC1.ICRC1;
/// Optional fee calculating function. Called for each item in the batch.
/// Example: return base_fee / number_of_transactions for discounted batch fees.
get_fee : ?((State, Environment, TransferBatchArgs, ICRC1.TransferArgs) -> Balance);
};The class has a register_transfer_batch_listener endpoint that allows other objects to register an event listener and be notified whenever a batch transfer occurs.
The events are synchronous and cannot directly make calls to other canisters. We suggest using them to set timers if notifications need to be sent using the Timers API.
Listener Types:
/// TransferBatchListener is a callback type used to listen to batch transfer events.
/// The <system> capability is required for timer operations.
public type TransferBatchListener = <system>(TransferBatchNotification, TransferBatchResult) -> ();Registration Function:
/// Register a listener to be notified when batch transfers complete.
/// The namespace should uniquely identify your listener to avoid conflicts.
public func register_transfer_batch_listener(namespace: Text, remote_func: TransferBatchListener);Usage Example:
// In your canister, register listeners after initialization
icrc4().register_transfer_batch_listener<system>("my_batch_tracker", func<system>(notification, results) {
// Handle batch transfer event
// Note: Cannot make inter-canister calls directly
// Use Timer.setTimer if you need async notifications
Debug.print("Batch transfer completed: " # Nat.toText(results.size()) # " transfers");
});The user may assign a function to intercept each transaction batch and each individual transaction just before it is committed to the transaction log. These functions are optional. The user may manipulate the values and return them to the processing transaction and the new values will be used for the transaction block information and for notifying subscribed components.
By returning an #err from these functions you will effectively cancel the transaction and the caller will receive back a #GenericError for that request with the message you provide.
Wire these functions up by including them in your call to transfer_batch_tokens.
public type TransferBatchNotification = {
from : Account;
transfers : [TransferArg];
memo : ?Blob;
created_at_time : ?Nat64;
};
public type TransactionRequestNotification = {
from : Account;
to : Account;
amount : Nat;
fee : ?Nat;
calculated_fee : Nat;
memo : ?Blob;
created_at_time : ?Nat64;
};
/// Optional synchronous or asynchronous functions triggered for the entire batch.
can_batch_transfer : ?{
#Sync : (notification: TransferBatchNotification) -> Result.Result<TransferBatchNotification, Text>;
#Async : (notification: TransferBatchNotification) -> async* Star.Star<TransferBatchNotification, Text>;
};
/// Optional synchronous or asynchronous functions triggered for each individual transfer.
can_transfer : ?{
#Sync : ((trx: Value, trxtop: ?Value, notification: TransactionRequestNotification) -> Result.Result<(trx: Value, trxtop: ?Value, notification: TransactionRequestNotification), Text>);
#Async : ((trx: Value, trxtop: ?Value, notification: TransactionRequestNotification) -> async* Star.Star<(trx: Value, trxtop: ?Value, notification: TransactionRequestNotification), Text>);
};This library was initially incentivized by ICDevs. You can view more about the bounty on the forum. If you use this library and gain value from it, please consider a donation to ICDevs.
Below is a complete example showing how to create an ICRC-1 token with ICRC-4 batch transfer functionality using the mixin pattern:
import ICRC1Mixin "mo:icrc1-mo/ICRC1/mixin";
import ICRC1 "mo:icrc1-mo/ICRC1";
import ICRC4Mixin "mo:icrc4-mo/ICRC4/mixin";
import ICRC4 "mo:icrc4-mo/ICRC4";
import ClassPlus "mo:class-plus";
import Principal "mo:core/Principal";
shared ({ caller = _owner }) persistent actor class MyToken(
icrc1Args : ?ICRC1.InitArgs,
icrc4Args : ?ICRC4.InitArgs
) = this {
transient let canisterId = Principal.fromActor(this);
transient let manager = ClassPlus.ClassPlusInitializationManager<system>(_owner, canisterId, true);
// Include ICRC-1 base token functionality
include ICRC1Mixin.mixin({
org_icdevs_class_plus_manager = manager;
args = icrc1Args;
pullEnvironment = ?func() : ICRC1.Environment {
{
advanced = null;
add_ledger_transaction = null;
var org_icdevs_timer_tool = null;
var org_icdevs_class_plus_manager = ?manager;
}
};
onInitialize = null;
});
// Include ICRC-4 batch transfer functionality
include ICRC4Mixin.mixin({
org_icdevs_class_plus_manager = manager;
args = icrc4Args;
pullEnvironment = ?func() : ICRC4.Environment {
{
icrc1 = icrc1();
get_fee = null; // Use default fee logic
}
};
onInitialize = null;
});
// Custom batch airdrop function example
public shared ({ caller }) func airdrop(recipients: [(ICRC1.Account, Nat)]) : async ICRC4.TransferBatchResults {
assert(caller == _owner);
await icrc4_transfer_batch({
transfers = Array.map<(ICRC1.Account, Nat), ICRC4.TransferArg>(recipients, func((to, amount)) {
{ to; amount; from_subaccount = null; fee = null; memo = null; created_at_time = null }
});
});
};
};let icrc1Args : ICRC1.InitArgs = {
name = ?"My Token";
symbol = ?"MTK";
decimals = 8;
logo = null;
fee = ?#Fixed(10_000); // 0.0001 tokens
max_supply = ?(1_000_000_000 * 100_000_000); // 1 billion tokens
minting_account = ?{ owner = _owner; subaccount = null };
min_burn_amount = ?(100_000); // 0.001 tokens
advanced_settings = null;
metadata = null;
max_memo = null;
fee_collector = null;
permitted_drift = null;
transaction_window = null;
max_accounts = null;
settle_to_accounts = null;
};
let icrc4Args : ICRC4.InitArgs = {
max_balances = ?3000; // Max accounts per balance query
max_transfers = ?3000; // Max transfers per batch
fee = ?#ICRC1; // Use ICRC-1 fee per transfer
};Executes multiple transfers in a single call. Each transfer is processed independently.
public shared func icrc4_transfer_batch(args: TransferBatchArgs) : async TransferBatchResults;
type TransferBatchArgs = {
transfers : [TransferArg]; // Array of individual transfers
};
type TransferArg = {
from_subaccount : ?Blob; // Source subaccount (null = default)
to : Account; // Destination account
amount : Nat; // Amount to transfer
fee : ?Nat; // Optional: override default fee
memo : ?Blob; // Optional: up to 32 bytes
created_at_time : ?Nat64; // Optional: for deduplication
};
type TransferBatchResults = [?TransferBatchResult];
type TransferBatchResult = { #Ok : Nat; #Err : TransferError };Important: Batch transfers are NOT atomic. Each transfer succeeds or fails independently. A null result at an index indicates that transfer was skipped (e.g., duplicate detection).
Queries multiple account balances in a single call.
public shared query func icrc4_balance_of_batch(args: BalanceQueryArgs) : async BalanceQueryResult;
type BalanceQueryArgs = {
accounts : [Account]; // Accounts to query
};
type BalanceQueryResult = [Nat]; // Balances in same order as inputReturns the maximum number of transfers allowed in a single batch.
public shared query func icrc4_maximum_update_batch_size() : async ?Nat;Returns the maximum number of accounts allowed in a balance query.
public shared query func icrc4_maximum_query_batch_size() : async ?Nat;Returns current ledger statistics and configuration.
public query func icrc4_get_stats() : async Stats;
type Stats = {
ledger_info : LedgerInfoShared;
};
type LedgerInfoShared = {
max_balances : Nat;
max_transfers : Nat;
fee : Fee;
};The following functions are available on the ICRC4 class for advanced use cases:
Returns current ledger configuration.
public func get_ledger_info() : LedgerInfo;Updates ledger configuration. Returns list of successfully applied updates.
public func update_ledger_info(updates: [UpdateLedgerInfoRequest]) : [Bool];
type UpdateLedgerInfoRequest = {
#MaxTransfers : Nat;
#MaxBalances : Nat;
#Fee : Fee;
};Returns ICRC-4 specific metadata entries.
public func metadata() : [MetaDatum];Critical: Batch transfers are NOT atomic. Each transfer in the batch succeeds or fails independently. Your application must:
- Check each result in the returned array
- Handle partial failures appropriately
- Consider retry logic for failed transfers
let results = await icrc4_transfer_batch({ transfers });
for (i in results.keys()) {
switch(results[i]) {
case(?#Ok(txId)) { /* Transfer i succeeded */ };
case(?#Err(err)) { /* Transfer i failed, handle error */ };
case(null) { /* Transfer i was deduplicated/skipped */ };
};
};ICRC-4 batch operations are particularly vulnerable to cycle drain attacks because a single call can contain thousands of transfer arguments. This library provides a two-layer defense.
Layer 1: Guards (built into the mixin, no action required)
The mixin automatically calls guard functions that trap on oversized inputs for inter-canister calls:
icrc4_transfer_batch → Inspect.guardTransferBatch() — validates batch size + each transfer
icrc4_balance_of_batch → Inspect.guardBalanceOfBatch() — validates array size + each account
Layer 2: Inspect (requires wiring in your actor)
For ingress message protection, you must add system func inspect() in your actor:
import ICRC1Inspect "mo:icrc1-mo/ICRC1/Inspect";
import ICRC4Inspect "mo:icrc4-mo/ICRC4/Inspect";
// In your persistent actor class
system func inspect(
{
arg : Blob;
msg : {
// ICRC-1 endpoints
#icrc1_balance_of : () -> ICRC1.Account;
#icrc1_transfer : () -> ICRC1.TransferArgs;
#icrc1_name : () -> ();
// ... other ICRC-1 endpoints
// ICRC-4 endpoints
#icrc4_transfer_batch : () -> ICRC4.TransferBatchArgs;
#icrc4_balance_of_batch : () -> ICRC4.BalanceQueryArgs;
#icrc4_maximum_update_batch_size : () -> ();
#icrc4_maximum_query_batch_size : () -> ();
// ... other endpoints
};
}
) : Bool {
// Check raw arg size FIRST — cheapest check
// For batch operations: max_transfers * ~200 bytes per transfer
let maxArgSize = 50_000; // 50KB max
if (arg.size() > maxArgSize) return false;
switch (msg) {
// ICRC-1
case (#icrc1_transfer(getArgs)) { ICRC1Inspect.inspectTransfer(getArgs(), null) };
case (#icrc1_balance_of(getArgs)) { ICRC1Inspect.inspectBalanceOf(getArgs(), null) };
// ICRC-4 — CRITICAL for batch operations
case (#icrc4_transfer_batch(getArgs)) { ICRC4Inspect.inspectTransferBatch(getArgs(), null) };
case (#icrc4_balance_of_batch(getArgs)) { ICRC4Inspect.inspectBalanceOfBatch(getArgs(), null) };
// Bounded-type endpoints — always accept
case (_) { true };
};
};The Inspect module (mo:icrc4-mo/ICRC4/Inspect) provides:
| Function | Purpose | Returns |
|---|---|---|
inspectTransferBatch(args, ?config) |
Validate batch size + each transfer | Bool |
inspectBalanceOfBatch(args, ?config) |
Validate array size + each account | Bool |
guardTransferBatch(args, ?config) |
Guard version — traps on invalid | () |
guardBalanceOfBatch(args, ?config) |
Guard version — traps on invalid | () |
configWithLedgerLimits(maxTransfers, maxBalances) |
Create config from your ledger limits | Config |
Pass null for config to use default limits. To use your ledger's actual limits for tighter validation:
let icrc4Config = ICRC4Inspect.configWithLedgerLimits(200, 3000);
ICRC4Inspect.inspectTransferBatch(args, ?icrc4Config);Default limits:
| Parameter | Default | Description |
|---|---|---|
maxMemoSize |
32 | ICRC-1 standard memo limit |
maxNatDigits |
40 | ~2^128 digit count |
maxSubaccountSize |
32 | Standard subaccount size |
maxBatchSize |
10,000 | Max transfers per batch |
maxBalanceQuerySize |
10,000 | Max accounts per balance query |
maxRawArgSize |
~2MB | Computed from batch limits |
See ICRC_fungible Token.mo for a complete inspect implementation covering ICRC-1 through ICRC-4.
Monitor cycle costs when performing batch operations. While batching is more efficient per-transfer, large batches consume more cycles in a single call. Consider:
- Breaking very large airdrops into multiple batches
- Using the
get_feeenvironment function for volume discounts
Version 0.2.x uses the Class+ initialization pattern. When upgrading:
-
Migration is automatic - The library uses the Class+ migration pattern. Your existing state will be preserved.
-
Breaking changes: None. The API is fully backward compatible.
The library supports two initialization patterns:
Recommended: Mixin Pattern (Class+)
include ICRC4Mixin.mixin({ ... });
// Access via icrc4()Legacy: Direct Instantiation
stable var icrc4_migration_state = ICRC4.init(ICRC4.initialState(), #v0_1_0(#id), args, caller);
let icrc4Instance = ICRC4.ICRC4(?icrc4_migration_state, principal, environment);The mixin pattern handles all boilerplate automatically and is recommended for new projects.
- ICRC-4 Specification
- ICRC-1 Base Standard
- Class+ Pattern
- Motoko Migrations
- It is recommended to thoroughly test your implementation of ICRC-4 in a controlled environment before deploying to the main network.