ERC-6909 Minimal Multi-Token Standard
The ERC-6909 token standard is a streamlined alternative to the ERC-1155 token standard.
The ERC-1155 standard introduced a multiple-token interface that allows a single smart contract to incorporate fungible and non-fungible tokens (i.e., ERC20 & ERC721).
ERC-1155 addressed several challenges, such as reducing deployment costs, minimizing redundant bytecode on the Ethereum blockchain, and streamlining token approvals for multi-token trading.
However, it introduced some bloat and gas inefficiency due to the mandated callbacks for every transfer, the forced inclusion of batch transfer, and a lack of fine-grained control of the single-operator approvals scheme. The ERC-6909 addresses these drawbacks by eliminating contract-level callbacks and batch transfers and replacing the single-operator permission scheme with a hybrid (allowance-operator) permission scheme for granular token management.
Note:
The following section assumes familiarity with the ERC-1155 standard and its concepts. If you are unfamiliar, please review the article before proceeding.
The Contrast Between ERC-6909 and ERC-1155 Standard
ERC-6909 removes the callback requirement for transfers
The ERC-1155 specification requires that safeTransferFrom
and safeBatchTransferFrom
check that the receiving account is a contract. If so, it MUST call the ERC1155TokenReceiver interface functions (onERC1155Received
, onERC1155BatchReceived
) on the recipient contract account to check if it accepts transfers.
These callbacks are helpful in some instances. However, they are unnecessary external calls to the recipient that wants to opt out of this behavior. The callbacks impact the gas cost and codesize of recipient contract accounts, as they require implementing multiple callbacks (i.e., via onERC1155Received
, onERC1155BatchReceived
) and return magic 4-byte values to receive the tokens. In contrast, ERC-6909 implementers are allowed to decide on their callback architecture.
ERC-6909 omits batch transfer logic
Batch transfers, while sometimes beneficial, are deliberately omitted from the ERC-6909 standard to allow developers to implement batch transfer logic tailored to specific execution environments. Developers can implement batch transfers as they see fit and do not have to add an additional batch transfer function simply to follow the standard.
The safeBatchTransferFrom
function, shown below, executes batch transfers in the ERC-1155 standard. However, its forced inclusion adds bloat for applications that do not need them:
// ERC-1155
function safeBatchTransferFrom(
address _from,
address _to,
uint256[] calldata _ids,
uint256[] calldata _values,
bytes calldata _data
) external;
Here is the ERC-6909 transferFrom
function. We can see that the batch feature and _data
parameter have been eliminated.
// ERC-6909
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();
if (senderAllowance != type(uint256).max) {
allowance[sender][msg.sender][id] = senderAllowance - amount;
}
}
if (balanceOf[sender][id] < amount) revert InsufficientBalance();
balanceOf[sender][id] -= amount;
balanceOf[receiver][id] += amount;
emit Transfer(msg.sender, sender, receiver, id, amount);
return true;
}
ERC-6909 supports both global approvals and granular allowances
// in ERC-1155 →
function setApprovalForAll(
address _operator,
bool _approved
) external;
The setApprovalForAll
function, shown above, is a global operator model in the ERC-1155 that allows an account to authorize another account to manage (act as an operator) all token IDs on their behalf. Once authorized, operators have unrestricted access to transfer any amount of any token ID owned by the authorizing account.
While this approach simplifies delegation, it lacks fine-grained control:
- There is no way to grant permissions specific to individual token IDs or amounts.
- This all-or-nothing approach will not align with scenarios requiring controlled permissions.
To introduce granular control, the ERC-6909 hybrid operator permission scheme incorporates the following:
- operator model from ERC-1155,
- and the allowance model inspired by ERC-20.
The operator model in ERC-6909
In the ERC-6909 setOperator
function, shown below, the spender
variable is set as an operator and granted global permissions to transfer all token IDs owned by the account without allowance restrictions.
function setOperator(address spender, bool approved) public returns (bool) {
isOperator[msg.sender][spender] = approved;
emit OperatorSet(msg.sender, spender, approved);
return true;
}
The allowance model in ERC-6909
The allowance model introduces a token-specific and amount-specific control system where an account can set a limited allowance for a specific token ID.
For example, Alice can permit Bob to transfer 100 units of Token ID 42 without granting access to other token IDs or unlimited amounts, using the approve
function in ERC-6909 shown next.
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;
}
The spender
variable in approve
is an account authorized to transfer specific amounts of a specific token ID on behalf of the token owner.
For instance, a token owner can allow spender
to transfer <= 100 units of a particular Token ID. Alternatively, they may also grant infinite approval for a specific token ID by setting the allowance to type(uint256).max
.
ERC-6909 does not specify whether allowances set to type(uint256).max
should be deducted or not. Instead, this behavior is left to the implementer’s discretion, similar to ERC-20.
Core Data Structures
ERC-6909 implementations use three mappings for updating the state of account balances and approvals.
balanceOf: Owner Balance of an ID
balanceOf
mapping tracks the balance of a specific token ID held by an address (owner
). The owner => (id => amount)
structure in the mapping indicates that a single owner can hold multiple tokens and track their balances via their respective IDs.
mapping(address owner => mapping(uint256 id => uint256 amount)) public balanceOf;
allowance: Spender Allowance of an ID
The allowance mapping defines how much of a specific token (ID) a spender can transfer on an owner’s behalf. It facilitates fine-grained control over token spending.
mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) public allowance;
For example, allowance[0xDEF...][0x123...][5]
will return the amount of tokens (with ID 5) owner 0xDEF...
has allowed spender 0x123...
to transfer.
isOperator: Operator Approval Status
mapping(address owner => mapping(address operator => bool isOperator)) public isOperator;
This mapping tracks whether a spender has been approved as an operator for all tokens owned by an address. For example, isOperator[0x123...][0xABC...]
returns true
if address 0xABC...
is allowed to spend tokens owned by address 0x123...
; otherwise, it returns false
.
Core ERC-6909 Functions & their Data Parameters
The Transfer Functions
This specification does not follow the “safe transfer mechanism” as seen in ERC-721 and ERC-1155, as the naming convention was deemed misleading, given they make external calls to arbitrary contracts. The ERC-6909 uses the transfer
and transferFrom
functions with the following details.
Transfer:
The ERC-6909 transfer
function behaves the same way as an ERC-20 transfer
, except that it applies to a specific token ID. The function takes the receiver address, the token’s ID, and the amount to be transferred as input parameters and updates balances using the balanceOf
mapping. As in the ERC-20 transfer function, it is necessary to return true if the transaction is executed successfully
//ERC-20 interface transfer function
function transfer(address _to, uint256 _value) public returns (bool)
// ERC-6909 transfer function Reference Implementation
// @notice Transfers an amount of an id from the caller to a receiver.
// @param receiver The address of the receiver.
// @param id The id of the token.
// @param amount The amount of the token.
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;
}
transferFrom:
ERC-6909’s transferFrom function differs from ERC-20’s by requiring a token ID. Additionally, it checks for operator approval in addition to allowance
The function first checks if (sender != msg.sender && !isOperator[sender][msg.sender])
, ensuring that the caller (msg.sender
) is either:
- The owner (
sender
), or - An approved operator (
isOperator[sender][msg.sender] == true
).
If msg.sender
is not the owner or an approved operator, the function checks whether the caller has enough allowance for the transfer. If an allowance exists but is not set to unlimited (type(uint256).max
), the transferred amount
is deducted from the allowance.
Furthermore, the standard specifies that the function SHOULD NOT subtract amount
from the caller’s allowance
of token id
if the caller is an operator or the sender
.
// ERC-6909 transferFrom
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();
if (senderAllowance != type(uint256).max) {
allowance[sender][msg.sender][id] = senderAllowance - amount;
}
}
if (balanceOf[sender][id] < amount) revert InsufficientBalance();
balanceOf[sender][id] -= amount;
balanceOf[receiver][id] += amount;
emit Transfer(msg.sender, sender, receiver, id, amount);
return true;
}
approve:
The approve
function allows the caller (msg.sender
) to grant a specific allowance of a token (ID) to a spender. This updates the allowance mapping to reflect the new allowance and emits an Approval
event.
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;
}
setOperator:
The setOperator
function allows the caller (msg.sender
) to grant or revoke operator permissions for a specific address (spender
) on their behalf by setting the approved parameter to true or false. The function updates the isOperator
mapping accordingly and emits an OperatorSet
event to notify external listeners of the change.
function setOperator(address spender, bool approved) public returns (bool) {
isOperator[msg.sender][spender] = approved;
emit OperatorSet(msg.sender, spender, approved);
return true;
}
Events & Loggings in ERC-6909
ERC-6909 defines key events to track token transfers, approvals, and operator permissions within a multi-token contract.
1. Transfer Event:
/// @notice The event emitted when a transfer occurs.
event Transfer(address caller, address indexed sender, address indexed receiver, uint256 indexed id, uint256 amount);
The Transfer
event in ERC-6909 is used to track token movements and must be emitted under the following conditions:
- The event is emitted when an
amount
of a token id
is transferred from one account to another. It logs the sender
, receiver
, token ID
, and the amount
transferred. - When new tokens are created, the event must be emitted with the
sender
as the zero address (0x0
). - When tokens are destroyed, the event must be emitted with the receiver as the zero address (
0x0
) to indicate token removal.
2. OperatorSet Event:
/// @notice The event emitted when an operator is set.
event OperatorSet(address indexed owner, address indexed spender, bool approved);
The OperatorSet
event is emitted whenever an owner assigns or revokes operator permissions for another address. The event logs the owner’s address, the spender’s address, and the updated approval status (true
for granted, false
for revoked).
3. Approval Event:
/// @notice The event emitted when an approval occurs.
event Approval(address indexed owner, address indexed spender, uint256 indexed id, uint256 amount);
The Approval
event must be emitted when an owner sets or updates an approval for a spender to transfer a specific amount of a given token ID on their behalf. The event logs the owner
, spender
, token id
, and approved amount
.
Now that we have explored the differences between ERC-6909 and ERC-1155, as well as the core methods and events in ERC-6909, let’s examine some real-life uses of the standard.
How Uniswap v4 PoolManager implements ERC-6909.
In Uniswap v3, the factory/pool model creates new token pairs by deploying a separate contract for each pool using the UniswapV3Factory contract. This method increases gas costs since every new pool requires a new contract deployment.
In contrast, Uniswap v4 introduces a singleton contract (PoolManager.sol
), which manages all liquidity pools as part of its internal state rather than requiring separate contract deployments. This design significantly reduces gas costs for pool creation.
Also, transactions involving multiple Uniswap pools in previous versions required token transfers and redundant state updates across multiple contracts. In Uniswap v4, instead of transferring ERC-20 tokens in and out of pools, the PoolManager
contract can centrally hold the ERC-6909 representations of users’ ERC-20 tokens.
For example, if a user provides liquidity for token A, they can later choose to withdraw their share and receive token A as an ERC-20 transfer to their wallet. However, if they opt to leave their tokens inside the protocol, the Uniswap v4 PoolManager
can mint ERC-6909 representations of their token balances to the LP instead of transferring ERC-20 tokens out of the contract — saving a cross-contract call. These ERC-6909 balances allow users to trade or interact within the protocol without needing to move tokens between wallets.
This means that when the user later trades token A for token B, instead of transferring ERC-20 tokens from their wallet, Uniswap simply updates their ERC-6909 balance within the pool.
Note: ERC-6909 is not used as LP tokens in Uniswap V4.
ERC-6909 metadata considerations for singleton defi architectures and NFT collections
Here is the IERC6909Metadata
interface which defines how the ERC-6909 standard associate metadata, such as name, symbols, and decimals, can be associated with individual tokens.
Note that the name
, symbol
, and decimals
functions below can change based on the id, allowing different tokens within the ERC-6909 to have different names, symbols, and decimals.
/// @notice Contains metadata about individual tokens.
interface IERC6909Metadata is IERC6909 {
/// @notice Name of a given token.
/// @param id The id of the token.
/// @return name The name of the token.
function name(uint256 id) external view returns (string memory);
/// @notice Symbol of a given token.
/// @param id The id of the token.
/// @return symbol The symbol of the token.
function symbol(uint256 id) external view returns (string memory);
/// @notice Decimals of a given token.
/// @param id The id of the token.
/// @return decimals The decimals of the token.
function decimals(uint256 id) external view returns (uint8);
}
For a DeFi protocol, we might have several LP tokens, and we might want to standardize them to all have the same number of decimals, such as 18. However, we might want the name and symbol to reflect different assets held by the pool.
In contrast, for NFTs, the decimals
value should always be set to 1, since NFTs are indivisible.
In a typical NFT collection (e.g., ERC-721), all tokens share the same name and symbol to represent the collection as a whole (e.g., “CryptoPunks” with the symbol “PUNK”). ERC-6909 allows us to follow the ERC-712 convention, where all NFTs in a collection share the same metadata.
Implementation example of ERC-6909 non-fungible token.
The ERC-6909 specification does not explicitly define a unique approach for supporting non-fungible tokens. However, the ID bits-splitting technique described in the ERC-1155 specification may be used to implement non-fungible tokens in ERC-6909. This approach encodes the collection ID and item ID into a single uint256
token ID using bit-shifting and addition operations.
function getTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
return (collectionId << 128) + itemId;
}
The ERC6909MultiCollectionNFT
contract below is an example of a non-fungible token (NFT) implementation that uses getTokenId
to generate a token ID from collectionId
and itemId
.
The mintNFT
function ensures that each tokenId
can only be minted once, regardless of the address. It tracks whether an NFT tokenId
has been minted globally using the mintedTokens
mapping.
Since the amount
variable is set to 1 in mintNFT
, the _mint(to, tokenId, amount)
call in the function will mint only one copy for a tokenid
. In any case where amount
> 1, the token would become fungible instead of non-fungible.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./ERC6909.sol";
contract ERC6909MultiCollectionNFT is ERC6909 {
struct NFT {
string uri;
}
mapping(uint256 => NFT) private _tokens;
mapping(uint256 => string) private _collectionURIs;
mapping(uint256 => bool) public mintedTokens;
event MintedNFT(address indexed to, uint256 indexed collectionId, uint256 indexed itemId, uint256 tokenId, string uri);
// Compute Token ID by concatenating collectionId and itemId
function getTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
return (collectionId << 128) + itemId;
}
function _mint(address to, uint256 tokenId, uint256 amount) internal {
balanceOf[to][tokenId] += amount;
emit Transfer(msg.sender, address(0), to, tokenId, amount);
}
function mintNFT(address to, uint256 collectionId, uint256 itemId, string memory uri) external {
uint256 amount = 1;
uint256 tokenId = getTokenId(collectionId, itemId);
require(!mintedTokens[tokenId], "ERC6909MultiCollectionNFT: Token already minted");
require(amount == 1, "ERC6909MultiCollectionNFT: Token copies must be 1");
_tokens[tokenId] = NFT(uri);
mintedTokens[tokenId] = true; // Mark as minted
_mint(to, tokenId, amount); // amount is defined as 1.
emit MintedNFT(to, collectionId, itemId, tokenId, uri);
}
function nftBalanceOf(address owner, uint256 tokenId) public view returns (uint256) {
return balanceOf[owner][tokenId];
}
}
Recall that the _mint
call in the mintNFT
, above, updates the balanceOf
mapping to 1, since the mints are entirely non-fungible. Hence, the nftBalanceOf
function is expected always to return 1 in this contract if the owner
address indeed minted tokenId
.
To transfer token ownership, the nftTransfer
function below ensures that only the NFT owner can initiate a transfer by verifying their balance before allowing the only existing unit to be transferred.
function nftTransfer(address to, uint256 tokenId) external {
require(balanceOf[tokenId][msg.sender] == 1, "ERC6909MultiCollectionNFT: This should be non-fungible.");
require(to != address(0), "ERC6909MultiCollectionNFT: transfer to zero address");
transfer(to, tokenId, 1);
// the amount in this case is equal to 1.
emit Transfer(msg.sender, address(0), to, tokenId, 1);
}
ERC-6909 content URI extensions and metadata URI JSON schema
To standardize metadata access in ERC-6909, the optional IERC6909ContentURI
interface defines two URI functions(contractURI
and tokenURI
) for retrieving metadata at contract and token levels. The ERC-6909 standard does not mandate that tokens have URI metadata associated with them. However, if an implementation includes these URI functions, the returned URIs should point to JSON files that conform to the ERC-6909 Metadata URI JSON Schema.
pragma solidity ^0.8.19;
import "./IERC6909.sol";
/// @title ERC6909 Content URI Interface
interface IERC6909ContentURI is IERC6909 {
/// @notice Contract level URI
/// @return uri The contract level URI.
function contractURI() external view returns (string memory);
/// @notice Token level URI
/// @param id The id of the token.
/// @return uri The token level URI.
function tokenURI(uint256 id) external view returns (string memory);
}
As shown in the code above, the ERC-6909 IERC6909ContentURI
interface defines two optional URI functions, namely contractURI
and tokenURI
; each with their corresponding URI JSON schema.
The contractURI
function (does not take any arguments) returns a single URI pointing to contract-level metadata, while the tokenURI()
returns a URI specific to each token ID.
Below is an example of how to structure the Contract URI JSON Schema, as specified by the ERC-6909 standard.
{
"title": "Contract Metadata",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the contract."
},
"description": {
"type": "string",
"description": "The description of the contract."
},
"image_url": {
"type": "string",
"format": "uri",
"description": "The URL of the image representing the contract."
},
"banner_image_url": {
"type": "string",
"format": "uri",
"description": "The URL of the banner image of the contract."
},
"external_link": {
"type": "string",
"format": "uri",
"description": "The external link of the contract."
},
"editors": {
"type": "array",
"items": {
"type": "string",
"description": "An Ethereum address representing an authorized editor of the contract."
},
"description": "An array of Ethereum addresses representing editors (authorized editors) of the contract."
},
"animation_url": {
"type": "string",
"description": "An animation URL for the contract."
}
},
"required": ["name"]
}
The tokenURI
function, on the other hand, takes in a uint256
parameter id
of a token and returns the token URI. The function MAY revert if the token id
does not exist. Clients interacting with the contract MUST replace every occurrence of {id}
in the URI with the actual token ID to access the correct metadata associated with that token.
Below is the implementation of the tokenURI
function, returning a static URI template that follows a placeholder format:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "./ERC6909.sol";
import "./interfaces/IERC6909ContentURI.sol";
contract ERC6909ContentURI is ERC6909, IERC6909ContentURI {
/// @notice The contract level URI.
string public contractURI;
/// @notice The URI for each id.
/// @return The URI of the token.
function tokenURI(uint256) public pure override returns (string memory) {
return "<baseuri>/{id}";
}
}
Here is an example of how to structure the URI JSON Schema, as specified by the ERC-6909 standard.
{
"title": "Asset Metadata",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Identifies the token"
},
"description": {
"type": "string",
"description": "Describes the token"
},
"image": {
"type": "string",
"description": "A URI pointing to an image resource."
},
"animation_url": {
"type": "string",
"description": "An animation URL for the token."
}
},
"required": ["name", "description", "image"]
}
Allowance & Operator ambiguity in ERC-6909 specification.
Consider a scenario where an account (A) grants operator permissions to another account (B) and also sets an allowance for B to transfer a specific amount of tokens.
If B initiates a transfer on behalf of A, the implementation must determine the correct order of checks and how allowances interact with operator permissions.
The ambiguity concerns the order of checks. Should the contract:
- Check the allowance first, reverting if it is insufficient, even if B has operator permissions.
- Check operator permissions first, allowing the transfer to proceed regardless of the allowance.
In the allowanceFirst
contract below, if account B has operator permissions but an insufficient allowance, the allowance check will fail, causing the transaction to revert. This could be counterintuitive because operator permissions typically imply unrestricted access, and users might expect the transaction to succeed.
Conversely, in the operatorFirst
contract, if the implementation checks operator permissions first, it will bypass the allowance check, and the transaction will succeed based on the operator’s unrestricted access.
contract operatorFirst {
function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public {
// check if `isOperator` first
if (msg.sender != sender && !isOperator[sender][msg.sender]) {
require(allowance[sender][msg.sender][id] >= amount, "insufficient allowance");
allowance[sender][msg.sender][id] -= amount;
}
// -- snip --
}
}
contract allowanceFirst{
function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public {
// check if allowance is sufficient first
if (msg.sender != sender && allowance[sender][msg.sender][id] < amount) {
require(isOperator[sender][msg.sender], "insufficient allowance");
}
// ERROR: when allowance is insufficient, this panics due to arithmetic underflow, regardless of
// whether the caller has operator permissions.
allowance[sender][msg.sender][id] -= amount;
// -- snip --
}
}
The standard deliberately leaves the decision on permission checks unconstrained, giving implementers the flexibility to choose. When an account has both operator permissions and an insufficient allowance, the transfer behavior depends on the order in which the checks are performed.
Conclusion
The ERC-6909 standard significantly improves on the efficiency of ERC-1155 by removing batching and the mandatory callbacks in the transfer functions. Removing batching allows for case-by-case optimization, particularly for rollups or gas-sensitive environments.
It also introduced scalable control of token approval through the hybrid operator permission scheme.
Acknowledgements
We would like to thank vectorized, jtriley, and neodaoist (co-authors of ERC-6909) for reviewing this article.