Skip to main content
MVP development
Create a top-notch MVP for your startup with us
> $1
billion the capitalization of our portfolio
120+
MVP for startups around the world

As a full-cycle software development company, we build the best minimum viable products ensuring your idea becomes a reality.

Learn more

ERC-6909: Minimal Multi-Token Interface and Why It Matters for Ethereum Projects

The ERC-6909 standard is an alternative to the ERC-1155: Multi Token Standard for managing multiple tokens from a single smart contract.

Key differences from ERC-1155:

  • The interface doesn't require implementing a callback mechanism for the token recipient.
  • No support for batch calls, where multiple token operations occur in a single transaction.
  • The token approval system for third-party usage has been redesigned.

The ERC-6909 interface provides minimal functionality, which helps reduce the smart contract code size and transaction execution costs.

Important! Any smart contract implementing ERC-6909 must support ERC-165: Standard Interface Detection by default.

Interesting! Vectorized, the developer behind projects like solady, ERC721A, and multicaller, participated in the development of the standard.

Reference Implementation

The reference implementation is taken from the ERC-6909 specification and simplified by me for quick understanding by developers. I recommend reviewing the reference first, then continuing with the rest of the article.

contract ERC6909 {
    /// @notice Owner Balances
    mapping(address owner => mapping(uint256 id => uint256 amount)) public balanceOf;

    /// @notice Granted permissions for third-party token usage
    mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) public allowance;

    /// @notice Operator permissions
    mapping(address owner => mapping(address spender => bool)) public isOperator;

    /// @notice Token transfer on behalf of the owner
    function transfer(address receiver, uint256 id, uint256 amount) public returns (bool) {
        if (balanceOf[msg.sender][id] < amount) revert InsufficientBalance(msg.sender, id);

        balanceOf[msg.sender][id] -= amount;
        balanceOf[receiver][id] += amount;

        emit Transfer(msg.sender, msg.sender, receiver, id, amount);
        return true;
    }

    /// @notice Token transfer by third parties, requires granted permission
    function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool) {
        if (sender != msg.sender && !isOperator[sender][msg.sender]) {
            uint256 senderAllowance = allowance[sender][msg.sender][id];

            if (senderAllowance < amount) revert InsufficientPermission(msg.sender, id);
            if (senderAllowance != type(uint256).max) {
                allowance[sender][msg.sender][id] = senderAllowance - amount;
            }
        }

        if (balanceOf[sender][id] < amount) revert InsufficientBalance(sender, id);

        balanceOf[sender][id] -= amount;
        balanceOf[receiver][id] += amount;

        emit Transfer(msg.sender, sender, receiver, id, amount);
        return true;
    }

    /// @notice Granting permission for third-party token transfer with a token amount limit
    function approve(address spender, uint256 id, uint256 amount) public returns (bool) {
        allowance[msg.sender][spender][id] = amount;
        emit Approval(msg.sender, spender, id, amount);
        return true;
    }

    /// @notice Granting permission for token transfer by an operator without a token amount limit
    function setOperator(address spender, bool approved) public returns (bool) {
        isOperator[msg.sender][spender] = approved;
        emit OperatorSet(msg.sender, spender, approved);
        return true;
    }

    function _mint(address receiver, uint256 id, uint256 amount) internal {
      balanceOf[receiver][id] += amount;
      emit Transfer(msg.sender, address(0), receiver, id, amount);
    }

    function _burn(address sender, uint256 id, uint256 amount) internal {
      balanceOf[sender][id] -= amount;
      emit Transfer(msg.sender, sender, address(0), id, amount);
    }
}

Changing the Balance Storage Structure

The balance storage structure is the first thing to pay attention to. Unlike ERC-1155, there are changes.

// ERC-1155 из OpenZeppelin
mapping(uint256 id => mapping(address account => uint256)) private _balances;

// ERC-6909 из OpenZeppelin
mapping(address owner => mapping(uint256 id => uint256)) private _balances;

The mapping responsible for storing an account's balance starts with the owner's address, not the token ID.

This fundamentally only affects the interaction interface. To retrieve all user balances, you still need to implement additional functions in the smart contract or index the data off-chain. This is because you need to know all token IDs owned by the account, and the base implementation doesn’t store this information by default.

No Callback

According to the ERC-1155 standard, a smart contract that acts as a token recipient must implement the ERC1155TokenReceiver interface.

This interface requires implementing one of the functions depending on the chosen token transfer method (single or batch):

-  function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data) external returns(bytes4);
-  function onERC1155BatchReceived(address _operator, address _from, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data) external returns(bytes4);

In ERC-6909, developers can still use callbacks, but the implementation is entirely up to them and can be arbitrary.

ERC-6909 does not define a callback mechanism. This helps reduce the size of the base smart contract implementation and the number of operations during execution, making it more efficient in terms of gas and complexity.

Changes in Token Transfers

Similar to callbacks, the standard takes the same approach with batch operations.

ERC-1155 requires the implementation of additional functions:

- function safeBatchTransferFrom(address _from, address _to, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data) external;
- function balanceOfBatch(address[] calldata _owners, uint256[] calldata _ids) external view returns (uint256[] memory);

ERC-6909 no longer mandates batch operations and doesn’t require their implementation just for the sake of standard compliance.

Batch operations can be added at the developer's discretion and tailored to the specific needs of the project.

Token Transfer Functions

The transfer is as close as possible to the ERC-20 standard implementation, with slight modifications.

- function transfer(address receiver, uint256 id, uint256 amount) public returns (bool);
- function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool);

Sender and receiver are the familiar from and to. An id is added to specify the token identifier involved in the transfer. The addition of id is very reminiscent of ERC-721 and ERC-1155.

Flexible Approval System

In ERC-1155, approvals can only be granted to an operator via a function call:

function setApprovalForAll(address _operator, bool _approved) external;

ERC-6909 introduces a hybrid approval system. There are two ways to grant approval:

  • To an operator, allowing unlimited use of tokens on behalf of the user. The operator can manage any of the user’s tokens (with any id).
  • To an arbitrary account, with a limit on the number of tokens it can use on behalf of the user.

Thus, the ERC-6909 interface provides two functions for implementing approval logic:

- function setOperator(address spender, bool approved) public returns (bool);
- function approve(address spender, uint256 id, uint256 amount) public returns (bool);

This mechanism is quite flexible, but there’s a nuance when an account is granted approval through both functions. In such cases, the standard performs checks in the following order:

  1. Check if the account is an operator.
  2. If not an operator, check the allowance granted via the approve() call.

Approval is only checked when using the transferFrom() function.

function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool) {
    // If the sender is transferring tokens themselves or has been granted operator rights, the tokens are sent immediately.
    // Otherwise, adjust the remaining allowance available to the caller.
    if (sender != msg.sender && !isOperator[sender][msg.sender]) {
        uint256 senderAllowance = allowance[sender][msg.sender][id];
        if (senderAllowance < amount) revert InsufficientPermission(msg.sender, id);

        if (senderAllowance != type(uint256).max) {
            allowance[sender][msg.sender][id] = senderAllowance - amount;
        }
    }

    // Balance updates and event emission

    return true;
}

Thus, for an operator who has been granted a limited approval (via the approve() function), the allowance will not be changed.

Token Metadata

With the ERC-6909 standard, you can implement both fungible and non-fungible tokens simultaneously.
The metadata implementation for such tokens is moved outside the core standard into a separate extension and is optional.

Why optional? The answer is simple: for managing LP tokens (or other types of tokens), properties like name, symbol, or URI might not be important. That’s why metadata is optional and excluded from the base implementation, but its usage is still standardized.

Currently, only the OpenZeppelin library implements metadata as an extension to the standard. (Solmate doesn’t have smart contracts for metadata, and in Solady the metadata is hardcoded into the base implementation).

Next, let’s take a look at how metadata smart contracts are implemented in OpenZeppelin.

Important! At the time of writing, everything related to ERC-6909 in OpenZeppelin is marked as draft.

ERC6909Metadata.sol

contract ERC6909Metadata {
    struct TokenMetadata {
        string name;
        string symbol;
        uint8 decimals;
    }

    mapping(uint256 id => TokenMetadata) private _tokenMetadata;

    function name(uint256 id) public view virtual returns (string memory) {
        return _tokenMetadata[id].name;
    }

    function symbol(uint256 id) public view virtual override returns (string memory) {
        return _tokenMetadata[id].symbol;
    }

    function decimals(uint256 id) public view virtual override returns (uint8) {
        return _tokenMetadata[id].decimals;
    }
}

There are two things of interest here:

  1. All functions — name(), symbol(), decimals() — take a single id argument. This means each token will have its own parameters.
  2. OpenZeppelin uses a combination of mapping and struct to store data — a classic approach for optimizing data storage.

ERC6909TokenSupply.sol

contract ERC6909TokenSupply {
    mapping(uint256 id => uint256) private _totalSupplies;

    function totalSupply(uint256 id) public view virtual override returns (uint256) {
        return _totalSupplies[id];
    }

    /// @dev Override the `_update` function to update the total supply of each token id as necessary.
    function _update(address from, address to, uint256 id, uint256 amount) internal virtual override {
      ...
    }
}

Total supply, just like name, symbol, and so on, is individual for each token.

ERC6909ContentURI.sol

contract ERC6909ContentURI is ERC6909, IERC6909ContentURI {
    string private _contractURI;
    mapping(uint256 id => string) private _tokenURIs;

    function contractURI() public view virtual override returns (string memory) {
        return _contractURI;
    }

    function tokenURI(uint256 id) public view virtual override returns (string memory) {
        return _tokenURIs[id];
    }
}

This contract stores metadata required for NFTs. contractURI is used to declare general collection data. tokenURI is used to declare individual metadata for each token.

Thus, by using a combination of metadata contract extensions — ERC6909Metadata, ERC6909TokenSupply, and ERC6909ContentURI — the standard can manage both fungible and non-fungible tokens at the same time.

Removal of "safe" Naming

The naming conventions safeTransfer() and safeTransferFrom() can be misleading, especially in the context of ERC-1155 and ERC-721 standards, since they require external calls to recipient addresses (if the recipient is a smart contract). This means control is passed to an arbitrary contract.

According to the ERC-6909 standard, removing the word "safe" from all function names is considered less misleading.

Real-World Usage

Unlike many proposed token standards, ERC-6909 was immediately tested in Uniswap v4.

ERC-6909 serves as proof of a user's assets within the protocol.

It works quite simply: after performing an operation (swap, removing liquidity), the user can leave their asset inside the protocol and receive an ERC-6909 token in return. Next time, to use the asset within the protocol, it's enough to burn the equivalent amount of ERC-6909.

For example, a user swaps USDT for USDC. They send USDT, but instead of receiving USDC, they mint an equivalent amount of ERC-6909. The USDC stays inside the protocol.
Later, the user decides to swap back to USDT using USDC. To do this, they simply burn the ERC-6909 token, and the protocol returns USDT.

In this way, ERC-6909 significantly reduces gas costs when moving assets.
Minting an ERC-6909 token is cheaper than transferring USDC in terms of gas. That’s because minting involves a single storage write and a Mint() event, while most tokens add extra checks during transfers—like whitelists or other custom logic.

This technology is especially useful for traders who perform many operations in a short period of time, and for liquidity providers who rebalance their positions.

More details in the official Uniswap documentation.

Conclusion

ERC-6909 is one of those rare standards that simplifies the system instead of making it more complex. Thanks to this simplification, any implementation of the standard is easier to understand, has a smaller footprint, and is cheaper to use with multiple tokens.

ERC-6909 is not backward compatible with ERC-1155!

That said, I want to highlight the ability to seamlessly combine management of both fungible and non-fungible tokens.
A single ERC-6909 smart contract can manage both ERC-20 tokens and NFTs.

Important! This comes with the caveat that all NFTs must be implemented within a single collection, since the contractURI() function does not support multiple collections.

Get a free consultation
Fill out the form to contact our manager.
By submitting the form, you agree to the Privacy Policy
Or you can book a call in Calendly calendly

More articles by this author

dao

Articles

education
web3

What Is DAO and How Does It Function?

Pavel Naydanov

Solidity developer

Featured story
pendle

Articles

ethereum
defi

Pendle - Liberating Yield

Pavel Naydanov

Solidity developer

Featured story
stacking_nft

Articles

ethereum
web3
dApps

Understanding NFT Staking: How It Operates and Its Advantages

Pavel Naydanov

Solidity developer

Hot
Hot
uniswap
Hot
pendle
Featured story

Pendle - Liberating Yield

Pavel Naydanov

Solidity developer

Articles

ethereum
defi
NFT_wallet

ERC721C: A New Approach to Royalty Payments

Alexei Kutsenko

Solidity developer

Articles

web3
NFT
ai_agent
Featured story
ai_blockchain
ton_predictions
ton_results
Featured story
eigenlayer
ton_doc
Featured story
bridges
Polygon_zkEVM
scroll

How Scroll Blockchain Works: A Technical Overview

Alexei Kutsenko

Solidity developer

Articles

ethereum
web3
zkp
bridges_overview
5_rules
Solana
Featured story
TON_jettons
blocks
Featured story
package_solution
Uma_protocol
Featured story
adsgram

How to Monetize Games on Telegram

Alexey Fedin

CEO at magnetto.pro

Articles

business
startup
TON
hamster_tap
Featured story
ggem
wine_vinesia
Featured story

Why Use Blockchain for Tokenizing Premium Alcohol

Elizaveta Chernaya

Brand Media Editor

Articles

ethereum
web3
business
dao

What Is DAO and How Does It Function?

Pavel Naydanov

Solidity developer

Articles

education
web3
l2
stacking_nft
Featured story
legendary_play
Featured story
payments
TON
sharding
Featured story
blockchain_top_companies
mvp_2024
launchpad

How to Successfully Create a Launchpad

Nico Bordunenko

Business Analyst at MetaLamp

Articles

web3
dApps
launchpad
aa zkp
Featured story
zksync
Featured story
rwa
Featured story
anonymus
Featured story

Zero-knowledge proof explained and 2024 Trends

Yevgeniy Biktimirov

Venture Analyst

Articles

ethereum
web3
dApps
cpay
Featured story
stock market chart
Featured story
planets
fundraising
Featured story
cto
wallet

How Account Abstraction Lowers Gas Fees in DApps

Nico Bordunenko

Business Analyst at MetaLamp

Articles

ethereum
web3
tokens
Featured story
rocketcomputer
Featured story

How to Create a Startup MVP Design in 7 Days

Julia Cherepanova

Head of Design Office

Articles

startup
MVP
design
crypto wallets
Featured story
speed up development
myths
Featured story

Myths About Blockchain Product Development Explained

Nico Bordunenko

Business Analyst at MetaLamp

Articles

web3
dApps
startup
mvp launching rocket
Featured story

Who Should Be on Your Team to Launch an MVP

Alexey Sukharev

Head of Sales Department

Articles

business
startup
MVP
galaxy
Featured story
magazine
Top_trends
cryptocoffee
investments
investors
squares

The First NFT Marketplace on Cardano: Our Story

Stanislav Zhdanovich

Haskell developer

Articles

cardano
web3
NFT
stair
bridge
abstraction

How We Use Our Training Program to Recruit Plutus Engineers

Svetlana Dulceva

The Education Program Supervisor

Articles

education
cardano
web3
mountains
computer in space

Discover Why IT Companies Appreciate Our Junior Developers

Svetlana Dulceva

The Education Program Supervisor

Articles

education
web2
business
salary
IT developer
cardano
chains
abstraction
salary

How to Effectively Motivate Developers

Roman Shtih

CEO Metalamp

Articles

business
purewhite
Featured story
rocket
a man with books
Featured story
Telegram Twitter LinkedIn Medium Facebook Instagram