ERC-6909 Minimal Multi-Token Interface
Final · NFTs & multi-token · created 2023-04-19
A minimal interface that manages multiple tokens by id in a single contract. It removes callbacks and batch transfers from ERC-1155 and uses per-token-id allowance approvals together with full operator approvals.
Why it is needed
The ERC-1155 multi-token standard calls a callback function on every transfer if the recipient is a contract account. The receiving contract must return a specified value. Because there is no way to choose a callback-free transfer as in ERC-721, unnecessary external calls occur, and the receiving contract must also provide several callback functions, which makes the code larger. Batch transfers are also part of the specification, so it is hard to change the encoding to suit environments such as rollups that need to reduce calldata size. There is also only one kind of permission, the operator, so once an account is approved it can move any token id in the contract without limit. ERC-6909 removes these elements and keeps only the minimal interface needed to manage multiple tokens in one contract.
What to implement
event Transfer(address caller, address indexed sender, address indexed receiver, uint256 indexed id, uint256 amount);: Emitted when caller moves amount of token id from sender to receiver. For a mint, sender is the zero address, and for a burn, receiver is the zero address.event OperatorSet(address indexed owner, address indexed spender, bool approved);: Emitted when owner sets the operator status of spender to the approved value.event Approval(address indexed owner, address indexed spender, uint256 indexed id, uint256 amount);: Emitted when owner sets the authority for spender to move amount of token id on its behalf.mapping(address owner => mapping(uint256 id => uint256 amount)) public balanceOf;: The balanceOf(owner, id) query function, which returns the amount of token id that owner holds.mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) public allowance;: The allowance(owner, spender, id) query function, which returns the amount of token id that spender can move on behalf of owner.mapping(address owner => mapping(address spender => bool)) public isOperator;: The isOperator(owner, spender) query function, which returns whether spender is an operator of owner.function transfer(address receiver, uint256 id, uint256 amount) public returns (bool): Sends amount of the caller's token id to receiver. It reverts if the balance is insufficient and returns true on success.function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool): Sends amount of sender's token id to receiver. If the caller is neither sender itself nor an operator, it checks and decreases the allowance.function approve(address spender, uint256 id, uint256 amount) public returns (bool): Sets the amount of token id that spender can move on behalf of the caller to amount.function setOperator(address spender, bool approved) public returns (bool): Designates spender as an operator for all of the caller's token ids (true) or revokes it (false).function supportsInterface(bytes4 interfaceId) public pure returns (bool supported): An ERC-165 query. It returns true when given 0x0f632fb3 or 0x01ffc9a7.
In depth
Definitions and interface ID
A contract that follows this standard must implement ERC-165 together with the interface below, and the interface ID is 0x0f632fb3. Terms follow the definitions in the original text. infinite is the maximum value of uint256 (2 ** 256 - 1). caller is the current caller (msg.sender). spender is an account that moves tokens on behalf of another account, and operator is an account with unlimited transfer authority over all token ids of another account. mint is token creation, and it MAY be handled by a separate mint method or by a transfer from the zero address. burn is token removal, and it MAY be handled by a burn method or by a transfer to the zero address.
Three query functions
balanceOf(owner, id) returns the total amount of token id that owner holds. allowance(owner, spender, id) is the total amount of token id that spender can move on behalf of owner. isOperator(owner, spender) returns true if spender is approved as an operator of owner. All three functions are view, and unlike balanceOf and allowance in ERC-20, they take a token id as an argument. In the reference implementation the three values are kept in public mappings, so query functions with the same names are generated automatically.
Mandatory rules for transfer and transferFrom
transfer(receiver, id, amount) sends the caller's token id to receiver. If the caller's balance is insufficient it MUST revert, and otherwise it must emit a Transfer event and return true. transferFrom(sender, receiver, id, amount) sends sender's tokens to receiver on the caller's behalf. If the caller is neither sender itself nor an operator of sender and the allowance for that id is insufficient, it must revert, and it must also revert if sender's balance is insufficient. If the caller is not an operator and the allowance is not infinite, it MUST decrease the caller's allowance by the amount by which sender's balance decreased. If the allowance is infinite, or the caller is an operator or sender itself, it SHOULD NOT decrease the allowance. Both functions must emit a Transfer event and return true.
approve and setOperator
approve(spender, id, amount) sets the amount of token id that spender can move on behalf of the caller. It MUST set the allowance to amount rather than adding to or subtracting from the existing value, and it must emit an Approval event and return true. Increase and decrease methods were left out deliberately to keep the interface small. setOperator(spender, approved) grants or withdraws spender's unlimited transfer authority over all of the caller's token ids. It must set the operator status to the approved value, emit an OperatorSet event, and return true. Because both mechanisms exist, you can choose among three levels of authority: a fixed amount for a specific id, unlimited for a specific id, and unlimited for all ids.
Three events
The Transfer event carries caller (not indexed), sender, receiver, and id (each indexed), and amount (not indexed). It must be emitted whenever an amount of token id moves from one account to another, and it must record sender as the zero address on a mint and receiver as the zero address on a burn. The OperatorSet event carries owner and spender (each indexed) and approved, and it must be emitted whenever operator status is set. It MAY also be emitted when the value is set to the same as before. The Approval event carries owner, spender, and id (each indexed) and amount, and it must be emitted when owner sets an allowance.
Optional extensions: metadata, content URI, total supply
All three extensions work per token id. The metadata extension provides name(id), symbol(id), and decimals(id), which return the name, ticker, and number of decimal places (uint8) for each id. The content URI extension provides contractURI() for the whole contract and tokenURI(id) for each token. tokenURI MAY revert for an id that does not exist, and the client MUST substitute {id} in the returned URI string. The contract metadata JSON requires name and treats description, image_url, banner_image_url, external_link, editors, and animation_url as optional. The token metadata JSON requires name, description, and image, with animation_url optional. The token supply extension returns the total supply per id through totalSupply(id).
What was removed, why, and backward compatibility
The original text explains why each element was removed from ERC-1155. First, mandatory callbacks are a burden for implementers who do not need callbacks or want to use their own mechanism, so they were removed. This reduces contract size, gas, and complexity. Second, batch processing is useful, but it is better decided per implementation than fixed in the standard. In environments such as rollups, where calldata is written to global storage, the calldata layout may need to be arranged differently. Third, the names safeTransfer and safeTransferFrom are misleading, because in practice they make an external call to a recipient account that has code and hand execution flow to an arbitrary contract. Removing both the mandatory callbacks and the word safe makes the default control flow safer. Fourth, because some methods are missing, it is not backward compatible with ERC-1155, but wrappers for ERC-20, ERC-721, and ERC-1155 can be built.
Order of operations in the reference implementation
In the reference implementation (Solidity 0.8.19), transferFrom reads the allowance only when sender != msg.sender and the caller is not an operator. If the allowance is less than amount, it reverts with InsufficientPermission(spender, id), and it decreases the allowance by amount only when the allowance is not type(uint256).max. It then checks the balance and reverts with InsufficientBalance(owner, id) if the balance is too low. supportsInterface returns true for 0x0f632fb3 and for ERC-165 itself, 0x01ffc9a7. Minting and burning are implemented as the internal functions _mint and _burn, which put address(0) in the sender or receiver of the Transfer event. A warning comment above both functions says that important safety checks must be done first.
Things to watch out for
- Delegated permissions stay valid until revoked: An account that has been given an allowance can move the full limit at any time and for any reason until the approval is revoked. An account that has been given operator permission can move any amount of any of the owner's token ids until it is revoked. This is a caveat common to every delegated permission model.
- Order of operator and allowance checks: When an account has operator permission but lacks the allowance needed for a particular transfer, checking the allowance first causes a revert, while checking the operator status first lets the transfer pass. The specification deliberately leaves this order undefined, for implementations that want to track allowances even for operators. If an implementation unconditionally subtracts the allowance, as the ERC6909AllowancePrecedence example in the original text does, execution halts with an arithmetic underflow even when the caller has operator permission.