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 Timestamp
s.
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.
from_subaccount
is an optionalSubaccount
(?Blob
) and specifies whether to use a specific 32 byte subaccount to send from. The senderAccount
(containing aPrincipal
) 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.to
is anAccount
and specifies the recipients accounts.amount
is aNat
and specifies the amount of tokens to be svariantent measured by the smallest subunits possible of the token (defined by the token decimals).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).memo
is a?Blob
and specifies optional 32 byte binary data to include with a transaction.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.
#BadFee
is returned when something is wrong with the senders fee inTransferArgs
and informs about the expected fee through its associated type{ expected_fee : Nat }
.#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 }
.#InsufficientFunds
is returned when the senderAccount
balance is smaller than theamount
to be sent plus fee (if required). It returns the senders balance through{ balance : Nat }
.#TooOld
and#CreatedInFuture
are returned if a transaction is not made within a specific time window. They are used for transaction deduplication.#TemporarilyUnavailable
is returned when token transfers are temporarily halted, for example during maintenance.#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.
icrc1_name
returns the name of the token as aText
.icrc1_symbol
return the ticker symbol as aText
.icrc1_decimals
returns the maximum number of decimal places of a unit token as aNat8
. Most tokens have 8 decimal places and the smallest subunit would be 0.00_000_001.icrc1_fee
returns the fee to be paid for transfers of the token as aNat
measured in smallest subunits of the token (defined by the token decimals). The fee may be 0 tokens.icrc1_total_supply
returns the current total supply as aNat
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.
-
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.
-
icrc1_balance_of
is a query function that takes anAccount
as an argument and returns the balance of that account as aNat
measured in smallest subunits of the token (defined by the token decimals). -
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 theTransferArgs
record as an argument and returns a resultResult<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
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 theValue
variant in the tuple.- The
Text
is namespaced and consists of two parts separated by a colon:namespace:key
. The first partnamespace
may not contain colons (:
) and represents a 'domain' of metadata. The second partkey
is the actual key which is a name for theValue
. - The
icrc1
namespace is reserved is reserved for keys from the ICRC1 standard itself, likeicrc1:name
,icrc1:symbol
,icrc1:decimals
andicrc1:fee
. Other keys could be added as part of theicrc1
metadata domain, for exampleicrc1:logo
. - Another domain of metadata could be
stats
for providing statistics about the distribution of tokens. Some keys could bestats:total_accounts
,stats:average_balance
andstats:total_tx
.
- The
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 Duration
s.
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 thannow - (window + drift)
, the response should an error#TooOld
. - If the
created_at_time
is larger thannow + drift
, the response should be an error#CreatedInFuture: { ledger_time : Timestamp }
whereledger_time
is equal tonow
. - 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 exactmemo
andcreated_at_time
. - Otherwise
created_at_time
falls within the time window and has no duplicates and thus should be accepted by returning#Ok(TxIndex)
whereTxIndex
is the index at which the transaction is recorded.