Multi-Token Ledger Standard

Hello everyone

I started this side-quest for my project https://forum.dfinity.org/t/xfusion-many-tokens-one-trade/55977 but it seems like there is far more demand to implement something similar for IC ecosystem.

Introducing..

MTLS: Multi-Token Ledger Standard

Abstract

MTLS (Multi-Token Ledger Standard) is a standard for managing multiple fungible tokens within a single canister on the Internet Computer. Unlike ICRC tokens, which follow a “one token per canister” model, MTLS enables efficient multi-token operations, batch transfers, and shared infrastructure.

Motivation

ICRC standards require deploying separate canisters for each token, leading to:

  • High deployment costs: Each token requires its own canister and cycles
  • Complex multi-token operations: Multi-token transfers require multiple inter-canister calls
  • Fragmented liquidity: Each token operates in isolation
  • Operational overhead: Managing multiple canisters for related tokens

MTLS addresses these limitations by enabling:

  • Cost efficiency: Single canister deployment for multiple tokens
  • Atomic operations: Multi-token transfers and batch operations in single calls
  • Shared infrastructure: Common allowance and approval systems
  • Simplified management: Unified token lifecycle management

Data Types

TokenId

A TokenId uniquely identifies a token within the multi-token ledger.

It MUST be a valid principal, derived using a canonical scheme:

TokenId = principal(sha256(mtls_canister_principal || nonce))
  • nonce is a strictly increasing counter maintained by the MTLS ledger.
  • Guarantees uniqueness and reproducibility.
type TokenId = principal;

TokenConfig

type TokenConfig = record {
    name : text;
    symbol : text;
    decimals : nat8;
    transfer_fee : nat;
    minting_account : Account;
    initial_supply : opt nat;
    max_supply : opt nat;
    metadata : vec record { text; Value };
};

Account

type Account = record {
    owner : principal;
    subaccount : opt blob;
};

TokenTransfer

type TokenTransfer = record {
    token_id : TokenId;
    from_subaccount : opt blob;
    to : Account;
    amount : nat;
    fee : opt nat;
    memo : opt blob;
    created_at_time : opt nat64;
};

Core Methods

mtls1_supported_tokens

mtls1_supported_tokens : () -> (vec TokenId) query;

mtls1_token_metadata

mtls1_token_metadata : (TokenId) -> (opt vec record { text; Value }) query;

mtls1_balance_of

mtls1_balance_of : (TokenId, Account) -> (opt nat) query;

mtls1_total_supply

mtls1_total_supply : (TokenId) -> (opt nat) query;

mtls1_transfer

mtls1_transfer : (TokenTransfer) -> (variant { Ok : nat; Err : TransferError });

mtls1_create_token

type CreateTokenError = variant {
    Unauthorized;
    TokenAlreadyExists;
    InvalidConfig : record { reason : text };
    TemporarilyUnavailable;
    GenericError : record { error_code : nat; message : text };
};
mtls1_create_token : (TokenConfig) -> (variant { Ok : TokenId; Err : CreateTokenError });

Lifecycle Methods

mtls1_mint

mtls1_mint : (TokenId, Account, nat) -> (variant { Ok : nat; Err : MTLSError });

mtls1_burn

mtls1_burn : (TokenId, Account, nat) -> (variant { Ok : nat; Err : MTLSError });

mtls1_update_token_metadata

mtls1_update_token_metadata : (TokenId, vec record { text; Value }) -> (variant { Ok; Err : MTLSError });

Advanced Methods

mtls1_multi_transfer

mtls1_multi_transfer : (vec TokenTransfer) -> (vec variant { Ok : nat; Err : TransferError });

Multi-Transfer Atomicity


Approval Operations

mtls1_approve

type TokenApproveArgs = record {
    token_id : TokenId;
    from_subaccount : opt blob;
    spender : Account;
    amount : nat;
    expected_allowance : opt nat;
    expires_at : opt nat64;
    fee : opt nat;
    memo : opt blob;
    created_at_time : opt nat64;
};
mtls1_approve : (TokenApproveArgs) -> (variant { Ok : nat; Err : ApproveError });

mtls1_transfer_from

type TokenTransferFromArgs = record {
    token_id : TokenId;
    spender_subaccount : opt blob;
    from : Account;
    to : Account;
    amount : nat;
    fee : opt nat;
    memo : opt blob;
    created_at_time : opt nat64;
};
mtls1_transfer_from : (TokenTransferFromArgs) -> (variant { Ok : nat; Err : TransferFromError });

mtls1_allowance

mtls1_allowance : (TokenId, record { account : Account; spender : Account }) -> (opt Allowance) query;

Fee Policy

type FeePolicy = variant {
  Burn;
  MintingAccount;
  Treasury : Account;
};
mtls1_fee_policy : (TokenId) -> (FeePolicy) query;

Transaction Schema

MTLS-1 extends ICRC-3 with token-aware transactions.

type Transaction = record {
    token_id : TokenId;
    operation : variant { Mint; Burn; Transfer; Approve; TransferFrom; CreateToken; UpdateMetadata };
    from : opt Account;
    to : opt Account;
    spender : opt Account;
    amount : nat;
    fee : opt nat;
    memo : opt blob;
    timestamp : nat64;
    tx_index : nat;
};

Log Access

public query func mtls1_get_transactions(token_id : TokenId, start : Nat, length : Nat) : async [Transaction];
public query func mtls1_get_transaction_count(token_id : TokenId) : async Nat;

Archive Delegation:
Ledger canisters MUST proxy transaction queries to archive canisters (like ICRC-3). Clients SHOULD only call the main ledger.

Token Lifecycle


Security Considerations

  • Each token MUST have isolated state (balances, allowances, logs, fees).
  • Multi-transfer MUST be atomic (all succeed or all fail).
  • Lifecycle ops MUST respect per-token minting_account authorization.

Metadata

Key Semantics Example
mtls1:version Standard version variant { Text = "1.0.0" }
mtls1:total_tokens Total number of tokens variant { Nat = 5 }
mtls1:batch_transfers Batch transfer support variant { Text = "true" }

Examples

Create Token

await actor.mtls1_create_token({
  name: "Example Token",
  symbol: "EXT",
  decimals: 8,
  transfer_fee: 10000,
  minting_account: { owner: principal, subaccount: null },
  initial_supply: [1000000000],
  max_supply: null,
  metadata: []
});

Multi-Transfer

await actor.mtls1_multi_transfer([
  { token_id: tokenA, to: { owner: recipient, subaccount: null }, amount: 1_000_000, from_subaccount: null, fee: null, memo: null, created_at_time: null },
  { token_id: tokenB, to: { owner: recipient, subaccount: null }, amount: 500_000, from_subaccount: null, fee: null, memo: null, created_at_time: null }
]);

References

5 Likes

Do you have any public code for this standard?
I think Dfinity should take action treat this as a serious standard, the atomic transactions is need for DEFI protocol

@infu what do you think about this?

I’ve raised the question 2y ago ICRC-1 multiple token ledgers inside one canister

@mariop came with a good idea - You don’t need to make another standard. Candid supports adding new fields to icrc1. Just don’t think of it like you are creating a new standard it will make it harder to understand, but more like adding a field token_id to functions. If someone doesn’t specify it - it works with the main token unless specified.

For example: there is no such thing as mtls1_mint and mtls1_burn, not needed. Transferring from/to the minter account makes it possible.

mtls1_multi_transfer - there is already icrc4 batch.

If you are going to redesign the whole ledger system or even add a token id field, just to get multiple tokens at this point, nobody will support it for years. Probably not worth doing at all. Our tech removes the need to have multi-token ledgers for a lot of needs, so you will be trying to solve problems that have already been solved.

You could definitely create your own ecosystem with completely different solutions, which may be better, but unless everyone drops everything they’ve been working on to switch to it, you will create yourself a lot more problems than you are solving.

5 Likes

also tagging @timo here in case he wants to jump in. he worked on a HPL (“high performance ledger”) in the past, which also aimed to support multiple tokens: Announcement: HPL - a ledger for 10k tps

I think there is a huge difference on this, as icrc4 is specifically targeting a single token (ledger) and you cannot transfer different tokens using by implementing icrc4.

independent of that I am curious if you are aware of different ledgers that already implemented icrc4? if so, please share :folded_hands:

yeah, that is certainly a risk. but we also talked about an “embeddable multi-token library” earlier this year. so maybe it would be a good start to have a standardized way for applications to handle multiple tokens within their own scope.

IIRC, @bob11 mentioned that this would have been valuable for him on Odin.Fun and might be interesting for other projects coming into the ecosystem.

I generally still have some doubts whether we can solve the composability of different ledgers and protocols in a reliable and error-preserving way. the lack of atomicity will remain challenging whenever we have to deal with inter-canister calls. but I think many projects are meanwhile doing a really good job in handling this, which is good :slight_smile:

can you elaborate a little bit more on this, specifically what you mean with “our tech” and how this removes the need to have multi-token ledgers? that would be good :folded_hands:

1 Like

It’s a good argument, but I don’t think just adding token_id to ICRC-1 methods is a small change.
Once you introduce token_id:

  • Balances, allowances, and fees all need per-token namespaces
  • Transaction logs and archives must include token_id and handle per-token partitioning
  • Batches become cross-token, requiring new atomicity guarantees
  • You also need discovery methods and a completely different archive model

At that point, I believe it’s basically dealing with a different standard, just without calling it one.

And importantly — this still doesn’t guarantee adoption. If wallets and explorers don’t implement the “tweaked ICRC-1,” it won’t be used anyways. And if they do spend time supporting it, they could just as well support MTLS or any other standard

Yes, I was talking about the multi transfer capability of the proposed mtls1_multi_transfer. icrc4 (dont think anyone has implemented it) but the idea is the same - pass array of transfers that get executed inside one call, instead of having to make N separate calls, which have bigger overhead.

He could just set Odin.Fun canister as minter for hundreds of external ledger canisters instead of trying to put a multi ledger inside the same canister. In fact he can make another canister to handle that while reading the Odin.Fun log.

I mean Neutrinite’s DeVeFi ledger middleware. You have a canister that communicates with ledgers directly using the log and icrc1. Mints, burns, transfers while the ledger is just a thing you spawn and don’t touch at all - no adding custom logic inside the ledger. This canister can control many ledgers fully if set as a minter. There isn’t something you can do with a multi-ledger canister that you can’t do with this approach. You will have to deploy your ledger WASMs, though, and pay cycles for them.

If you want to redesign the system completely and use all the lessons learned, best if you think you are making a blockchain canister, not multi multi-ledger. Transactions go in - get processed, and blocks (log) gets out. Then ledger transfers and capabilities are just one feature of the blockchain canister. You can have different canisters attached to that blockchain canister which process custom contracts and add blocks to it, that can be replayed. Custom transactions go in these canisters; the blockchain canister is allowing them to write to it. But they can also read from it and reduce their state. Off-chain systems can reduce their state as well. Each of these has all WASMs stored and a range of transactions for each WASM, so that history can be replayed. Off-chain systems can also fetch the WASMs and run transactions through something like pocket-ic to get the latest state. Something like that, giving us way more than we have now, would be worth changing the current system

Wouldn’t that be

  • Unnecessarily costly
  • Canister management nightmare?

Let’s say you want to bring every single pump.fun token into IC ecosystem. What would be associated costs?

afaik, Omnity already spawns a unique icrc1 ledger for all Odin.Fun Runes tokens living on ICP that can be bridged between ICP and Bitcoin.

just to be clear, that embeddable multi-token library I am referring to would be a possible complement to (external) icrc1 ledgers, but limited to application scope. think of it like an easy way to deal with multiple tokens within the application scope, enabling atomicity and transparency. that was at least my personal interpretation of this approach.

I am not saying we urgently need it, I am just saying that it has been discussed and that it might be useful for several applications that want to deal with tokens inside their canisters. not every application needs (or wants) their (“bridged”) tokens to be transferable outside their system.

1 Like

Well, if you bring them in a single canister, the transaction fees you need to charge so your canister doesn’t get flooded and stops working will be something like 20$ per tx.

I’d love to see the math behind higher tx fees :slight_smile:

as of January 2025, over 6 million meme coins had been launched via Pump.fun

I don’t think we need to proceed with much more math ha. Canisters have limits. Someone can vibecode 10 lines of script that move a token back and forth between accounts and if your tx fee isn’t high enough the whole thing stops working. You are going to have to utilize a lot of canisters on a lot of subnets.

The reason Solana can handle that much token transfers is because these ledgers are inside the protocol fine tuned for it. It’s like someone added token ledger handling code next to inter-canister calls code. On the IC we are building them inside the application layer with no special treatment.

That would require proper mitigation of flooding the canisters, right. You may face the same problem with ICRC-1 as well, and I believe there are some mechanisms to avoid getting spammed. Transfer fees, for example.

Alternative that you are suggesting is spinning up 6 million canisters, which I feel is way more costly and non-maintainable.

You can still handle ∞ amount of tokens on the IC. Inside a single canister. If you think of it like a blockchain. You pay a gas fee to add a transaction. Then your tokens ops look like the BTC Rune protocol. You put off-chain indexer and you are ready to go. Mint 100mil ledgers and 1000bil user accounts if you want. You can also try with on-chain indexer, but to scale that much u probably need off-chain indexer. You just wont have icrcX_balance_of at all, you are only sending transactions with meta protocol code inside

You should join the working groups. We were just discussing multi-canister tokens last week! I’ve have collaborated on and drafted various iterations of ICRC-80 over the last 18 months. (Why it has been sitting idle for 18 months is a long story that I’ll not relay here)

In reality, there has been much progress as significant infrastructure to support all of this has been necessary, and thus the work on making orchestration, installation, independence, verifiability, and DAO oversight-without-burden. But here are the current high-level drafts. ICRC-80 is also fundamentally intertwined with ICRC-8(Market Intents), which should see the light of day soon as the collaborating organization has the code in the can for a marketplace with all the React goodies for whitelabeling a roll-your-own token or NFT marketplace built on interoperable standards.

TLDR - 80 is compatible with 1+2 in that no actual change has to be made to wallets for it to “work”. The backend change is that the subaccount becomes the token ID and you can’t co-mingle across subaccounts. Ie, if Principal X sends tokens at subaccount Y to Principal Z at subaccount J, the canister should trap. So in this sense, wallets may initially be confused, but you shouldn’t be able to lose funds through accident.

You do lose subaccounts in ICRC-80, but I think that is ok in this sense given that canisters can still transact with source canisters and the advantages of them working over ICRC-1/2 give speed to uptake.

ICRC Title Author Discussions Status Type Category Created
80 Core Multi-Token Canister Standard DFINITY Community Reserved for Multi-canister shenanigans · Issue #80 · dfinity/ICRC · GitHub Draft Standards Track Financial 2024-XX-XX

1. Introduction

ICRC-80 defines the foundational architecture for multi-token canisters on the Internet Computer, using the extended Subaccount mechanism known as TokenPointer to uniquely represent and manage multiple token types within a single canister. This standard establishes account, transaction, and metadata models adapted for token multiplexing.

All block type schemas are specified in [ICRC-80-Blocks], named token association in [ICRC-80-Named-tokens], and escrow/deposit/withdraw mechanisms are governed exclusively by [ICRC-Escrow]. This document provides only the core protocol and behavioral definitions — implementers must refer to associated standards for these auxiliary topics.

2. Data Representations

2.1 TokenPointer

TokenPointer generalizes the concept of subaccount (as in ICRC-1), allowing each account to refer to a specific token within the canister or to an external token reference. Under ICRC-80, every transfer, balance inquiry, and approval is determined by matching both the principal (owner) and TokenPointer (subaccount), which functions as the token identifier.

TokenPointer Structure:

  • Length Byte: The first byte indicates the length of the data, including the flag byte.

  • Data: Principal bytes or numeric token ID. Last byte in this segment is the flag.

  • Flag Byte: Specifies the pointer type:

  • 0x01 — External ICRC-2 principal token.

  • 0x7f — Local (native) canister token.

  • 0x7d — External ICRC-80 principal token (with ID).

  • 0x7e — External ICRC-7 principal token (NFT).

  • 0x7c — Named token (see ICRC-80-Named-Tokens).(Intents?)

  • Padding: Pad with zeros to 32 bytes.

  • Default: 0x00..00 (32 bytes) or null for the canister’s primary/native token.

Example TokenPointer Usages

  • Transfer of local token id 123 (flag 0x7f) between two owners:

  • Both sides must use identical TokenPointer in subaccount field.

  • Holding external ICRC-2 token (flag 0x01):

  • TokenPointer encodes the target canister principal with 0x01 flag and zero-padding.

For schema, construction, and textual presentation rules, see above. To construct TokenPointers, use icrc8_construct_token_id (see below).

3. Main Functions

3.1 Overview

All ICRC-1, ICRC-2, and ICRC-4 methods are implemented to operate over accounts where each token type is segregated via its TokenPointer (subaccount) value. Unless otherwise specified, all references to ‘subaccount’ below mean TokenPointer.

Mandatory Subaccount (TokenPointer) Matching

Operations involving transfer of value (via icrc1_transfer, icrc2_transfer_from, or batch transfer) must verify that the source and destination TokenPointer match exactly. If not, the canister must return:


type GenericError = record { error_code : nat; message : text };

  • error_code : 80

  • message : "Incompatible Subaccount"

3.2 ICRC-1, ICRC-2, and ICRC-4 Method Adaptations

icrc1_transfer

Transfer a quantity of a specified token by subaccount.

  • If source and destination TokenPointers/subaccounts do not match, reject as above.

  • Fees (if any) are implementation defined per-token.

icrc2_approve/icrc2_transfer_from

Authorize and execute third-party spending for a target token.

  • Approvals are TokenPointer-specific.

  • Spender may use any subaccount they control, but execution must match TokenPointer rigorously.

icrc4_transfer_batch

Batch process multiple transfers.

  • Each transfer entry must specify appropriate TokenPointer and all must individually pass subaccount matching.

Query Methods

  • icrc1_balance_of — Returns the balance for a specific TokenPointer/principal pair.

  • icrc2_allowance — Returns allowance set for a TokenPointer.

  • icrc4_balance_of_batch — Batch balance queries for multiple TokenPointer/principal pairs.

Metadata Extensions

ICRC-80 provides additional token-specific metadata endpoints:


icrc80_name_by_id : (TokenPointer) -> (text) query;

icrc80_symbol_by_id : (TokenPointer) -> (text) query;

icrc80_decimals_by_id : (TokenPointer) -> (nat8) query;

icrc80_fee_by_id : (TokenPointer) -> (nat) query;

icrc80_total_supply_by_id : (TokenPointer) -> (nat) query;

icrc80_metadata_by_id : (TokenPointer) -> (vec record { text; Value }) query;

icrc80_maximum_query_batch_size_by_id : (TokenPointer) -> (opt nat) query;

icrc80_maximum_update_batch_size_by_id : (TokenPointer) -> (opt nat) query;

Other Utility Methods

  • icrc80_tokens(prev: opt TokenPointer, take : opt nat) -> (vec record { TokenPointer; nat }) query

  • Returns a list of all tokens in the canister and their supplies.

  • icrc8_construct_token_id(vec TokenIdentifierRequest) -> (vec opt TokenPointer) query

  • Request construction of TokenPointers for local or ICRC-2 tokens.

  • icrc80_balance_of(BalanceOfRequest, prev: opt TokenPointer, take: opt nat) -> (vec record { TokenPointer; nat }) query

  • Retrieve all token balances for a principal, optionally filtered.

3.3 Fee Handling

Fees for token transfer and other operations are defined at the token implementation level. All fee schedules are token-specific and accessible via metadata methods.

4. Error Handling

All state-changing methods must strictly verify subaccount/TokenPointer match as described. On violation, a GenericError with error code 80 and message Incompatible Subaccount must be returned. Permission and allowance errors must conform to ICRC-1/2 standards.

5. References to Related Standards

  • Block Schemas: All block definitions, remote transfer schemas, and related fields are specified in [ICRC-80-Blocks], not this document.

  • Named Tokens: See [ICRC-80-Named-Tokens] for methods and models for named tokens and mapping to external assets.

  • Escrow, Deposit, Withdrawal: Functionality for escrow, remote deposit, and withdrawal is covered in the [ICRC-Escrow] standard. No methods for these operations appear in ICRC-80 other than the requirements to reference them as needed.

6. Examples

Example: Account Creation and TokenPointer Use

Suppose Alice holds three tokens in a canister:

  • The canister’s native token (default TokenPointer):

{ owner = alice_principal; subaccount = null }

  • An external ICRC-2 token ICPT (principal ryjl3-tyaaa-aaaaa-aaaba-cai):

{ owner = alice_principal; subaccount = ?("0x0A...01") } // Constructed using TokenPointer rules

  • A local token for this canister (id = 5; flag 0x7f):

{ owner = alice_principal; subaccount = ?("0x02...7f") }

Example: Transfer Attempt with Mismatched TokenPointer

If a user tries to transfer from { owner: A, subaccount: T1 } to { owner: B, subaccount: T2 } where T1 ≠ T2:

  • The method must fail with:

Err(GenericError { error_code = 80; message = "Incompatible Subaccount" })

Example: Querying Token Metadata

To obtain the name/symbol/fee etc of a token, pass the correct TokenPointer to the respective icrc80_X_by_id method. If the TokenPointer is not a valid token in the canister, errors follow ICRC metadata conventions.

7. Out of Scope

  • Block definitions/formats — Provided in [ICRC-80-Blocks]

  • Named tokens, mapping, bridging — See [ICRC-80-Named-Tokens]

  • Escrow, deposit, withdrawal mechanisms — Exclusively managed in [ICRC-Escrow]; this standard only references their use.


This draft is subject to change pending final review and related standard maturity.

ICRC-80-Escrow

ICRC-80-Escrow: Standard Escrow Interface for Multi-Token Ledgers

ICRC Title Author(s) Discussions/Issue Status Type Category Created
Escrow Escrow Canister Standard for Multi-Token Ledgers DFINITY Community GitHub · Where software is built Draft Standards Track Financial 2024-XX-XX

Abstract

ICRC-80-Escrow defines a standardized escrow mechanism for ICRC-80 (and similar multi-token) ledgers on the Internet Computer. It provides a common interface for securely holding, releasing, and withdrawing digital assets, enabling use cases such as cross-canister transfers, conditional payments, and bridging to external systems. Deposit and withdrawal features are explicitly not governed by the core ICRC-80 standard and must follow this Escrow specification.

1. Motivation and Scope

The ICRC-80-Escrow standard:

  • Is designed for ledgers which manage multiple token types using ICRC-80.
  • Separates escrow, deposit, and withdrawal logic from the base token/trade methods.
  • Defines safe, interoperable ways for tokens to be held conditionally and released upon predetermined outcomes (e.g., bridging, delayed delivery, or dispute resolution).
  • Is referenced by ICRC-80 and other token standards for all escrow-related operations.

Not in scope: Non-token assets, custom dispute resolution processes, or non-deterministic smart contract flows.

2. Key Actors and Definitions

  • Escrow Canister: Implements the ICRC-80-Escrow interface, responsible for securely holding tokens on behalf of parties.
  • Depositor: Principal or canister placing tokens under escrow.
  • Beneficiary: Target recipient of tokens upon escrow completion.
  • Arbiter/Resolver: Optional third-party with authority to settle, dispute, or resolve escrowed funds.
  • EscrowID: Unique identifier for an escrow instance.
  • Escrow State: Life cycle phases—Initialized, Funded, Released, Reverted, Expired.

3. Overview of Escrow Mechanisms

Typical Escrow Workflow

  1. Escrow Created: An escrow intent is registered by the depositor. Parameters (token type, amount, beneficiary, conditions, timeouts) are set.
  2. Deposit/Fund: Escrow canister receives tokens (typically via an icrc2_transfer_from or similar call) from the depositor. Escrow state becomes Funded.
  3. Release/Withdrawal: Upon meeting release conditions (manual approval, cross-chain event, timeout, etc.), beneficiary (or escrow logic) calls to release funds. Tokens are sent to the beneficiary’s account via ledger interaction.
  4. Reversion/Expire: If conditions fail or escrow is canceled, funds are returned to the depositor.

Integration note: All deposit and withdrawal for ICRC-80 tokens must be routed through the standard ICRC-80-Escrow workflow.

4. Method Specifications and Types

Type Definitions

type EscrowID = nat;
type TokenPointer = blob; // As per ICRC-80
type Account = record { owner: principal; subaccount: opt blob };

type EscrowState = variant { Initialized; Funded; Released; Reverted; Expired };

type EscrowTerms = record {
    token: TokenPointer;
    amount: nat;
    depositor: Account;
    beneficiary: Account;
    arbiter: opt principal;
    release_condition: opt text; // Implementation-dependent
    timeout: opt nat64; // UTC timestamp in seconds
};

type EscrowRecord = record {
    id: EscrowID;
    terms: EscrowTerms;
    state: EscrowState;
    created_at: nat64;
    updated_at: nat64;
};

type EscrowError = variant {
    NotFound;
    NotAuthorized;
    AlreadyFunded;
    NotFunded;
    InvalidState;
    Timeout;
    ReleaseConditionNotMet;
    GenericError: record { error_code: nat; message: text };
};

Methods

1. escrow_create

Registers a new escrow intent and returns an EscrowID.

escrow_create: (EscrowTerms) -> (variant { Ok: EscrowID; Err: EscrowError });

2. escrow_fund

Performs the transfer of tokens (typically by calling an underlying icrc2_transfer_from or similar method), moving tokens into escrow. May only be called after escrow_create.

escrow_fund: (EscrowID) -> (variant { Ok: (); Err: EscrowError });

3. escrow_release

Releases the escrow to the beneficiary after confirmation or release conditions are met. Can be limited to the arbiter or determined by canister logic.

escrow_release: (EscrowID) -> (variant { Ok: (); Err: EscrowError });

4. escrow_revert

Reverts/unwinds the escrow in error or if terms are not met (state is not released/finalized). Funds return to the depositor.

escrow_revert: (EscrowID) -> (variant { Ok: (); Err: EscrowError });

5. escrow_status

Returns details for a given escrow instance.

escrow_status: (EscrowID) -> (variant { Ok: EscrowRecord; Err: EscrowError }) query;

6. escrow_list

Returns a paginated list of escrow records (e.g., by user, status, or all).

escrow_list: (owner: principal, prev: opt EscrowID, take: opt nat) -> (vec EscrowRecord) query;

5. Integration with ICRC-80

  • ICRC-80-compliant ledgers must remove direct deposit and withdrawal methods, and instead reference ICRC-80-Escrow for such flows.
  • When a remote deposit or withdrawal is required (e.g., cross-canister transfer or bridging), ICRC-80 ledgers shall invoke the relevant escrow_create and escrow_fund methods to initiate the escrow, and escrow_release or escrow_revert for completion.
  • ICRC-80 blocks referring to remote deposit/withdrawal should reference the corresponding EscrowID and EscrowRecord for audit and reconciliation.

6. Security Considerations

  • Escrow canisters must carefully validate state transitions to avoid double release or re-entrancy.
  • Only authorized actors (depositor, beneficiary, arbiter) should be able to trigger sensitive callbacks.
  • Timeouts and release logic should be deterministic and auditable.
  • Major security boundaries include correct accounting for token pointers and integration with underlying ledger for actual token movement.

7. Example Flows

Example: Cross-Chain Bridging

  1. User initiates an escrow (escrow_create) for a cross-chain deposit.
  2. Escrow canister holds tokens, waiting for cross-chain event.
  3. Upon proof or event, escrow_release is triggered to release tokens to the target beneficiary/subaccount.
  4. If rejected or timeout, escrow_revert returns tokens.

Example: Conditional Payout (e.g., marketplace)

  1. Buyer deposits funds into escrow via escrow_create + escrow_fund.
  2. Upon delivery confirmation (or arbiter approval), escrow_release transfers tokens to seller.

8. Standard Metadata

Escrow canisters should expose metadata, such as supported standards:

icrc_standards: () -> (vec record { name: text; url: text }) query;

9. Changelog

  • 2024-XX-XX: Initial draft.

ICRC-80-Escrow-Blocks

Note: ICRC-80-Escrow has a significant overlap with ICRC-84 and the two may/should be combined.

ICRC Title Author Discussions Status Type Category Created
80-Escrow-Blocks Block Types for ICRC-80-Escrow DFINITY Community Reserved for Multi-canister shenanigans · Issue #80 · dfinity/ICRC · GitHub Draft Standards Track Financial 2024-XX-XX

1. Introduction

ICRC-80-Blocks formally defines the standard block types (schemas and structures) for multi-token canisters following [ICRC-80]. This document exclusively governs:

  • The concrete structure and field requirements for blocks representing remote deposit and remote withdrawal actions within the ICRC-80 multi-token context.
  • Block schema serialization and validation rules for these block types (not for canister methods or canister business logic).
  • Context: Blocks are used for ledger/chain records, cross-canister, and cross-chain settlement.

SCOPE:

  • This standard does not cover canister methods, business logic, TokenPointer definition, named tokens, or escrow mechanisms. It exclusively specifies block structure and block serialization for remote deposit/withdraw.
  • All canister-side deposit, withdrawal, and escrow protocols are defined in [ICRC-Escrow]. TokenPointer structure and multi-token account logic is defined in [ICRC-80].

2. Block Format and Conventions

Blocks in this standard follow the general ICRC-3 style: a map-based structure encoded via variant/vec record pairs, with a btype text key to identify the block type and a tx map (record) containing type-specific fields. All blobs, nats, and arrays are to be encoded as in ICRC-3 and ICRC-1 standards.

Block Identifiers

  • The field btype (variant { Text }) MUST be present and exact:
    • “80ERemoteDeposit” for remote deposit blocks
    • “80ERemoteWithdrawal” for remote withdrawal blocks

Account Representation

  • All account fields (from, to) MUST encode a principal (as Blob, 29 bytes) and a subaccount (as Blob, typically 32 bytes), forming a tuple: Array(vec {Blob = principal; Blob = subaccount}). See [ICRC-80] for TokenPointer format.

3. Block Type: 80ERemoteDeposit

Purpose

Represents tokens deposited (minted to) an ICRC-80 canister as a result of approved cross-canister or remote ledger transfer.

Schema

  • btype (Required): Must be the string “80RemoteDeposit”
  • tx (Map) Required fields:
    • from (Required): Array(vec {Blob (principal), Blob (subaccount)}); account sending funds from source ledger
    • to (Required): Array(vec {Blob (principal), Blob (subaccount)}); account receiving tokens inside ICRC-80
    • canister (Required): Blob; principal of the source canister/ledger or external reference
    • amount (Required): Nat; amount transferred
  • tokenId (Optional): Blob or Nat;
    • If the token is an ICRC-80/ICRC-7/ICRC-NFT, tokenId specifies the token, otherwise omitted. Can be Nat (recommended) or Blob format where relevant.

Example (ICRC-3 Encoded Pseudocode)

variant { Map = vec {
    record { "btype"; variant { Text = "80RemoteDeposit" }};
    record { "tx"; variant { Map = vec {
        record { "from"; variant { Array(vec {Blob = <principal_bytes>, Blob = <subaccount_bytes>})}};
        record { "to"; variant { Array(vec {Blob = <principal_bytes>, Blob = <subaccount_bytes>})}};
        record { "canister"; variant { Blob = <principal_bytes> }};
        record { "amount"; variant { Nat = <amount_value> }};
        record { "tokenId"; variant { Nat = <token_id_value> }}; // only if applicable
    }}};
}};

Minimum fields: btype, from, to, canister, amount

Example (Sample Values)

variant { Map = vec {
    record { "btype"; variant { Text = "80RemoteDeposit" }};
    record { "tx"; variant { Map = vec {
        record { "from"; variant { Array(vec {Blob = principal_A; Blob = subA})}};
        record { "to"; variant { Array(vec {Blob = icrc80_canister; Blob = icrc80_token_pointer})}};
        record { "canister"; variant { Blob = remote_ledger_canister }};
        record { "amount"; variant { Nat = 1000 }};
    }}};
}};

4. Block Type: 80ERemoteWithdrawal

Purpose

Represents tokens withdrawn (burned or exported) from an ICRC-80 canister to an external ledger or destination, commonly for bridging, burn-and-mint, or remote settlements.

Schema

  • btype (Required): The string “80RemoteWithdrawal”
  • tx (Map) Required fields:
    • from (Required): Array(vec {Blob (principal), Blob (subaccount)}); account sending tokens inside ICRC-80
    • to (Required): Array(vec {Blob (principal), Blob (subaccount)}); remote account to receive tokens
    • canister (Required): Blob; principal or identifier of remote canister/ledger
    • amount (Required): Nat; amount withdrawn/transferred
  • tokenId (Optional): Blob or Nat; as above, present for non-default tokens

Example (ICRC-3 Encoded Pseudocode)

variant { Map = vec {
    record { "btype"; variant { Text = "80RemoteWithdrawal" }};
    record { "tx"; variant { Map = vec {
        record { "from"; variant { Array(vec {Blob = <principal_bytes>, Blob = <subaccount_bytes>})}};
        record { "to"; variant { Array(vec {Blob = <principal_bytes>, Blob = <subaccount_bytes>})}};
        record { "canister"; variant { Blob = <principal_bytes> }};
        record { "amount"; variant { Nat = <amount_value> }};
        record { "tokenId"; variant { Nat = <token_id_value> }}; // only if applicable
    }}};
}};

Minimum fields: btype, from, to, canister, amount

Example (Sample Values)

variant { Map = vec {
    record { "btype"; variant { Text = "80RemoteWithdrawal" }};
    record { "tx"; variant { Map = vec {
        record { "from"; variant { Array(vec {Blob = principal_A; Blob = subA})}};
        record { "to"; variant { Array(vec {Blob = principal_B; Blob = subB})}};
        record { "canister"; variant { Blob = remote_ledger_canister }};
        record { "amount"; variant { Nat = 200 }};
    }}};
}};

5. Guidelines and Validation

  • All conformant implementations MUST verify required fields are present and of correct type.
  • tokenId is strictly optional but must be included (and of the correct type) for non-default/non-native tokens as indicated by ICRC-80.
  • All variant and record structures must follow encoding rules in ICRC-1/ICRC-3; field order is not guaranteed but labels must match exactly.
  • These block types must not contain custom fields outside the prescribed schema for interoperability.

6. Relations and References

  • ICRC-80: This standard is referenced in the Core multi-token canister (ICRC-80), which emits, consumes, or queries these blocks for remote transfer actions.
  • ICRC-80-Escrow: Deposit, withdrawal, and escrow protocols are defined elsewhere; this document only describes the on-chain/off-chain record format.
  • ICRC-80-Named-Tokens: No content here relates to named tokens except as may influence field assignment in “tokenId”.
  • See ICRC-3 for encoding/serialization patterns and detailed variant/map conventions.

7. Revision History

  • v0.1 Initial Draft – Defines 80ERemoteDeposit, 80ERemoteWithdrawal block types strictly for ICRC-80.

ICRC-8-Named-tokens

Pending draft, but this allows named tokens, chainfusion tokens, basically extensibility.

3 Likes

Thank you! I’ll dig into it.

I’ll definitely join the working group meetings on this topic

1 Like

One way of achieving backward compatibility is to have:

  1. a new multi token ledger with a new interface that is specialized for the purpose and thus not ICRC-1/2 compliant, and
  2. for each token a proxy canister with an ICRC-1/2 interface that forwards to the multi-token canister and is recognized by the multi-token canister to have authority over the token.

Then existing wallets will go through the proxy and can slowly adopt the new interface over time. The new interface has all the new stuff like batches and atomic multi-token transfers, but only new wallets can use it.

3 Likes

Definitely :100:

Some proxy canister with ICRC-1/2 interface - for forwarding calls to multi-token one

In that case if someone desperately needs backwards compatibility, they can deploy such canister and use it