Skip to content

icdevsorg/icrc4.mo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

icrc4.mo

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.

Install

mops add icrc4-mo

Usage

import ICRC4 "mo:icrc4-mo/ICRC4";

Initialization

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.

Init Args

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

Environment

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

Event System

Subscriptions

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

Overrides

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

Funding

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.


Complete Usage Example

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

Recommended InitArgs

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

API Reference

ICRC-4 Standard Endpoints

icrc4_transfer_batch

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).

icrc4_balance_of_batch

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 input

icrc4_maximum_update_batch_size

Returns the maximum number of transfers allowed in a single batch.

public shared query func icrc4_maximum_update_batch_size() : async ?Nat;

icrc4_maximum_query_batch_size

Returns the maximum number of accounts allowed in a balance query.

public shared query func icrc4_maximum_query_batch_size() : async ?Nat;

Stats Endpoint

icrc4_get_stats

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

Internal Helper Functions

The following functions are available on the ICRC4 class for advanced use cases:

get_ledger_info

Returns current ledger configuration.

public func get_ledger_info() : LedgerInfo;

update_ledger_info

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

metadata

Returns ICRC-4 specific metadata entries.

public func metadata() : [MetaDatum];

Security Considerations

Non-Atomic Batch Transfers

Critical: Batch transfers are NOT atomic. Each transfer in the batch succeeds or fails independently. Your application must:

  1. Check each result in the returned array
  2. Handle partial failures appropriately
  3. 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 */ };
  };
};

Cycle Drain Protection

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

ICRC-4 Inspect Module API

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.

Fee Considerations

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_fee environment function for volume discounts

Migration Guide

Upgrading from v0.1.x to v0.2.x

Version 0.2.x uses the Class+ initialization pattern. When upgrading:

  1. Migration is automatic - The library uses the Class+ migration pattern. Your existing state will be preserved.

  2. Breaking changes: None. The API is fully backward compatible.

Using Class+ vs Legacy Pattern

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.


Additional Resources

About

ICRC-4 for Motoko

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors