ICRC1

Checkout the official documentation for ICRC1

ICRC1 is a standard for fungible tokens on the Internet Computer (IC). The standard specifies the types of data, the interface and certain functionality for fungible tokens on the IC.

The standard is defined in a Candid file accompanied by an additional description of the intended behavior of any ICRC1 token.

NOTE
ICRC is an abbreviation of 'Internet Computer Request for Comments' and is chosen for historical reasons related to token developments in blockchains such as Ethereum and the popular ERC standards (Ethereum Request for Comments)

On this page

ICRC1 Types
    General Types
    Account Types
    Transaction Types

ICRC1 Interface
    General token info
    Ledger functionality
    Metadata and Extensions

Transaction deduplication
    IC time and Client time
    Deduplication algorithm

How it works

In essence, an ICRC1 token is an actor that maintains data about accounts and balances.

The token balances are represented in Motoko as simple Nat values belonging to certain accounts. Transferring tokens just comes down to subtracting from one balance of some account and adding to another balance of another account.

Because an actor runs on the IC blockchain (and is therefore is tamperproof), we can trust that a correctly programmed actor would never 'cheat' and that it would correctly keep track of all balances and transfers.

NOTE
If the token actor is controlled by one or more entities (its controllers), then its security depends on the trustworthiness of the controllers. A fully decentralized token actor is one that is controlled by eiter a DAO or no entity at all!

ICRC1 types

The required data types for interacting with an ICRC1 token are defined in the official ICRC-1.did file. We will cover their Motoko counterparts here. We have grouped all ICRC1 types in three groups: general, account and transaction types.

General types

The standard defines three simple types that are used to define more complex types.

type Timestamp = Nat64;

type Duration = Nat64;

type Value = {
    #Nat : Nat;
    #Int : Int;
    #Text : Text;
    #Blob : Blob;
};

The Timestamp type is another name for a Nat64 and represents the number of nanoseconds since the UNIX epoch in UTC timezone. It is used in the definition of transaction types.

The Duration type is also a Nat64. It does not appear anywhere else in the specification but it may be used to represent the number of nanoseconds between two Timestamps.

The Value type is a variant that could either represent a Nat, an Int, a Text or a Blob value. It is used in the icrc1_metadata function.

Account types

A token balance always belongs to one Account.

type Account = {
    owner : Principal;
    subaccount : ?Subaccount;
};

type Subaccount = Blob;

An Account is a record with two fields: owner (of type Principal) and subaccount. This second field is an optional Subaccount type, which itself is defined as a Blob. This blob has to be exactly 32 bytes in size.

This means that one account is always linked to one specific Principal. The notion of a subaccount allows for every principal on the IC to have many ICRC1 accounts.

Default account

Each principal has a default account associated with it. The default account is constructed by setting the subaccount field to either null or supplying a ?Blob with only 0 bytes.

Account examples

Here's how we declare some accounts:

import Principal "mo:base/Principal";
import Blob "mo:base/Blob";

// Default account
let account1 : Account = {
    owner = Principal.fromText("un4fu-tqaaa-aaaab-qadjq-cai");
    subaccount = null;
};

// Account with specific subaccount
let subaccount : ?Blob = ?Blob.fromArray([1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);

let account2 : Account = {
    owner = Principal.fromText("un4fu-tqaaa-aaaab-qadjq-cai");
    subaccount;
};

account1 is the default account for the principal un4fu-tqaaa-aaaab-qadjq-cai. It is constructed by first making a Principal from the textual representation and making subaccount equal to null.

For account2 we make a custom Blob from a [Nat8] array with 32 bytes. For no particular reason, we set the first four bytes to 1. We also used name punning for the subaccount field.

NOTE
We use the same principal (un4fu-tqaaa-aaaab-qadjq-cai) for both accounts. A principal can have many accounts.

One principal, many accounts

In our last example, we used a ?Blob with 32 bytes for our subaccount. The first 4 bytes already allow for 256 * 256 * 256 * 256 = 4_294_967_296 different subaccounts.

The maximum amount of subaccounts for each principal is much much larger and equals 256^32 = 2^256 = 1.16 * 10^77, a natural number with 78 digits! A number like that can be represented in Motoko by a single Nat.

Textual representation of an Account

https://github.com/dfinity/ICRC-1/pull/98

Transaction types

The standard specifies two data types for the execution of token transfers (transactions) from one account to another. These are the TransferArgs record and the TransferError variant, which are used as the argument and part of the return type of the icrc1_transfer function.

type TransferArgs = {
    from_subaccount : ?Subaccount;
    to : Account;
    amount : Nat;
    fee : ?Nat;
    memo : ?Blob;
    created_at_time : ?Timestamp;
};

type TransferError = {
    #BadFee : { expected_fee : Nat };
    #BadBurn : { min_burn_amount : Nat };
    #InsufficientFunds : { balance : Nat };
    #TooOld;
    #CreatedInFuture : { ledger_time : Timestamp };
    #Duplicate : { duplicate_of : Nat };
    #TemporarilyUnavailable;
    #GenericError : { error_code : Nat; message : Text };
};

The TransferArgs record has six fields, four of which are optional types. Only the to and amount fields have to be specified for the most basic transaction.

  1. from_subaccount is an optional Subaccount (?Blob) and specifies whether to use a specific 32 byte subaccount to send from. The sender Account (containing a Principal) is NOT specified because this will be inferred from the transfer function, which is a caller identifying function. This ensures that no one can spend tokens except the owner of the account.
  2. to is an Account and specifies the recipients accounts.
  3. amount is a Nat and specifies the amount of tokens to be svariantent measured by the smallest subunits possible of the token (defined by the token decimals).
  4. fee is a ?Nat that specifies an optional fee to be payed by the sender for a transaction measured by the smallest subunits possible of the token (defined by the token decimals).
  5. memo is a ?Blob and specifies optional 32 byte binary data to include with a transaction.
  6. created_at_time is a ?Timestamp which specifies an optional transaction time for a transaction which maybe used for transaction deduplication.

The TransferError variant is used as the possible error type in the return value of the icrc1_transfer function. It specifies several failure scenarios for a transaction.

  1. #BadFee is returned when something is wrong with the senders fee in TransferArgs and informs about the expected fee through its associated type { expected_fee : Nat }.
  2. #BadBurn is returned when a burn transaction tries to burn a too small amount of tokens and informs about the minimal burn amount through { min_burn_amount : Nat }.
  3. #InsufficientFunds is returned when the sender Account balance is smaller than the amount to be sent plus fee (if required). It returns the senders balance through { balance : Nat }.
  4. #TooOld and #CreatedInFuture are returned if a transaction is not made within a specific time window. They are used for transaction deduplication.
  5. #TemporarilyUnavailable is returned when token transfers are temporarily halted, for example during maintenance.
  6. #GenericError allows us to specify any other error that may happen by providing error info through { error_code : Nat; message : Text }.

The records in the associated types of TransferError may contain even more information about the failure by subtyping the records. The same applies for the fields of TransferArgs. An implementer of a token may choose to add more fields for their application and still be compliant with the standard.

ICRC1 Interface

For a token to comply with ICRC1 it must implement a set of specified public shared functions as part of the actor type. The token implementation may implement more functions in the actor and still remain compliant with the standard, see actor subtyping.

We have grouped the functions into three groups and used an actor type alias ICRC1_Interface which is not part of the standard:

import Result "mo:base/Result";

type Result<Ok, Err> = Result.Result<Ok, Err>;

type ICRC1_Interface = actor {

    // General token info
    icrc1_name : shared query () -> async (Text);
    icrc1_symbol : shared query () -> async (Text);
    icrc1_decimals : shared query () -> async (Nat8);
    icrc1_fee : shared query () -> async (Nat);
    icrc1_total_supply : shared query () -> async (Nat);

    // Ledger functionality
    icrc1_minting_account : shared query () -> async (?Account);
    icrc1_balance_of : shared query (Account) -> async (Nat);
    icrc1_transfer : shared (TransferArgs) -> async (Result<Nat, TransferError>);

    // Metadata and Extensions
    icrc1_metadata : shared query () -> async ([(Text, Value)]);
    icrc1_supported_standards : shared query () -> async ([{
        name : Text;
        url : Text;
    }]);

};

General token info

The standard specifies five query functions that take no arguments and return general info about the token.

  1. icrc1_name returns the name of the token as a Text.
  2. icrc1_symbol return the ticker symbol as a Text.
  3. icrc1_decimals returns the maximum number of decimal places of a unit token as a Nat8. Most tokens have 8 decimal places and the smallest subunit would be 0.00_000_001.
  4. icrc1_fee returns the fee to be paid for transfers of the token as a Nat measured in smallest subunits of the token (defined by the token decimals). The fee may be 0 tokens.
  5. icrc1_total_supply returns the current total supply as a Nat measured in smallest subunits of the token (defined by the token decimals). The total supply may change over time.

Ledger functionality

ICRC1 intentionally excludes some ledger implementation details and does not specify how an actor should keep track of token balances and transaction history. It does specify three important shared functions to interact with the ledger, regardless of how the implementer of the standard chooses to implement the ledger.

  1. icrc1_minting_account is a query function that takes no arguments and returns ?Account, an optional account. The token ledger may have a special account called the minting account. If this account exists, it would serve two purposes:

    • Token amounts sent TO the minting account would be burned, thus removing them from the total supply. This makes a token deflationary. Burn transactions have no fee.
    • Token amounts sent FROM the minting account would be minted, thus freshly created and added to the total supply. This makes a token inflationary. Mint transactions have no fee.
  2. icrc1_balance_of is a query function that takes an Account as an argument and returns the balance of that account as a Nat measured in smallest subunits of the token (defined by the token decimals).

  3. icrc1_transfer is the main and most important function of the standard. It is the only update function that identifies its caller and is meant to transfer token amounts from one account to another. It takes the TransferArgs record as an argument and returns a result Result<Nat, TransferError>.

This function should perform important checks before approving the transfer:

  • Sender and receiver account are not the same
  • Sender has a balance bigger than amount to be sent plus fee
  • Sender fee is not too small
  • The subaccounts and memo are exactly 32 bytes
  • The transaction is not a duplicate of another transaction
  • The amount is larger than the minimum burn amount (in case of a burn transaction)
  • And any other logic that we may want to implement...

If anything fails, the function returns #Err(txError) where txError is a TransferError indicating what has gone wrong.

If all checks are met, then the transfer is recorded and the function returns #Ok(txIndex) where txIndex is a Nat that represents the transaction index for the recorded transaction.

Metadata and Extensions

  1. icrc1_metadata is a query function that takes no arguments and returns an array of tuples (Text, Value). The array may be empty. The tuples represent metadata about the token that may be used for client integrations with the token. The tuple represents a key-value store. The data does not have to be constant and may change in time. Notice that we use the Value variant in the tuple.
    • The Text is namespaced and consists of two parts separated by a colon: namespace:key. The first part namespace may not contain colons (:) and represents a 'domain' of metadata. The second part key is the actual key which is a name for the Value.
    • The icrc1 namespace is reserved is reserved for keys from the ICRC1 standard itself, like icrc1:name, icrc1:symbol, icrc1:decimals and icrc1:fee. Other keys could be added as part of the icrc1 metadata domain, for example icrc1:logo.
    • Another domain of metadata could be stats for providing statistics about the distribution of tokens. Some keys could be stats:total_accounts, stats:average_balance and stats:total_tx.
  2. icrc1_supported_standards is a query function that returns an array of records { name : Text; url : Text }. Each record contains info about another standard that may be implemented by this ICRC1 token. The ICRC1 token standard is intentionally designed to be very simple with the expectation that it will be extended by other standards. This modularity of standards allows for flexibility. (Not every token needs all capabilities of advanced token ledgers)

Transaction deduplication

Usually, when a client makes a transfer, the token canister responds with a Result<Nat, TransferError>. This way, the client knows whether the transfer was successful or not.

If the client fails to receive the transfer response (for some reason, like a network outage) from the canister, it has no way of knowing whether the transaction was successful or not. The client could implement some logic to check the balance of the account, but that's not a perfect solution because it would still not know why a transfer may have been rejected.

To offer a solution for this scenario (a client misses the response after making a transfer and doesn't know whether the transaction was successful), ICRC1 specifies transaction deduplication functionality. An identical transaction submitted more than once within a certain time window is will not be accepted a second time.

The time window could be a period of say 24 hours. This way, a frequent user (like an exchange or trader perhaps) could label their transactions with a created_at_time and a (possibly unique) memo. The client could, in the case of a missed response, send the same transaction again within the allowed time window and learn about the status of the transaction.

If the initial transaction was successful, a correct implementation of ICRC1 would not record the transfer again and notify the client about the existing transaction by returning an error #Duplicate : { duplicate_of : Nat } with an index of the existing transaction (a Nat value).

IC time and Client time

An actor (the host) who receives a transaction can get the current IC time through the Time Base Module.

A client could be a canister and in that case it would also get its time from the IC. But a client could also be a browser. In both cases, the host time and client time are not perfectly synced.

The client may specify a created_at_time which is different from the IC time as perceived by an actor who receives a transactions at some point in time.

The token canister may even receive a transaction that appears to have been made in the future! The reason for this is that client and IC are not in perfect time sync.

Deduplication algorithm

If a transaction contains both a valid memo and a created_at_time, then a correct implementation of ICRC1 should make two checks before accepting the transaction:

  • The transaction should fall within a specific time window
  • The transaction is not a duplicate of another transaction within that time window

This time window is measured relative to IC time. Lets declare the IC time and relevant Durations.

import Time "mo:base/Time";

let now = Time.now();

let window : Duration = 24 * 60 * 60 * 10**9;

let drift : Duration = 60 * 10**9;

The time window spans from now - (window + drift) to now + drift.

  • If the created_at_time is smaller than now - (window + drift), the response should an error #TooOld.
  • If the created_at_time is larger than now + drift, the response should be an error #CreatedInFuture: { ledger_time : Timestamp } where ledger_time is equal to now.
  • If the transaction falls within the time window, then there must not exist a duplicate transaction in the ledger within the same window. If its does exist, an error #Duplicate : { duplicate_of : Nat } should be returned. A duplicate transaction is one with all parameters equal to the current transaction that is awaiting approval, including the exact memo and created_at_time.
  • Otherwise created_at_time falls within the time window and has no duplicates and thus should be accepted by returning #Ok(TxIndex) where TxIndex is the index at which the transaction is recorded.