Alert Source Discuss
⚠️ Review Standards Track: ERC

ERC-6909: Minimal Multi-Token Interface

A minimal specification for managing multiple tokens by their id in a single contract.

Authors JT Riley (@jtriley-eth), Dillon (@d1ll0n), Sara (@snreynolds), Vectorized (@Vectorized), Neodaoist (@neodaoist)
Created 2023-04-19
Requires EIP-165

Abstract

The following specifies a multi-token contract as a simplified alternative to the ERC-1155 Multi-Token Standard. In contrast to ERC-1155, callbacks and batching have been removed from the interface and the permission system is a hybrid operator-approval scheme for granular and scalable permissions. Functionally, the interface has been reduced to the bare minimum required to manage multiple tokens under the same contract.

Motivation

The ERC-1155 standard includes unnecessary features such as requiring recipient accounts with code to implement callbacks returning specific values and batch-calls in the specification. In addition, the single operator permission scheme grants unlimited allowance on every token ID in the contract. Backwards compatibility is deliberately removed only where necessary. Additional features such as batch calls, increase and decrease allowance methods, and other user experience improvements are deliberately omitted in the specification to minimize the required external interface.

According to ERC-1155, callbacks are required for each transfer and batch transfer to contract accounts. This requires potentially unnecessary external calls to the recipient when the recipient account is a contract account. While this behavior may be desirable in some cases, there is no option to opt-out of this behavior, as is the case for ERC-721 having both transferFrom and safeTransferFrom. In addition to runtime performance of the token contract itself, it also impacts the runtime performance and codesize of recipient contract accounts, requiring multiple callback functions and return values to receive the tokens.

Batching transfers, while useful, are excluded from this standard to allow for opinionated batch transfer operations on different implementations. For example, a different ABI encoding may provide different benefits in different environments such as calldata size optimization for rollups with calldata storage commitments or runtime performance for environments with expensive gas fees.

A hybrid allowance-operator permission scheme enables granular yet scalable controls on token approvals. Allowances enable an external account to transfer tokens of a single token ID on a user’s behalf w by their ID while operators are granted full transfer permission for all token IDs for the user.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Every ERC-6909 compliant contract must implement the ERC-165 interface in addition to the following interface.

Definitions

  • infinite: The maximum value for a uint256 (2 ** 256 - 1).
  • caller: The caller of the current context (msg.sender).
  • spender: An account that transfers tokens on behalf of another account.
  • operator: An account that has unlimited transfer permissions on all token ids for another account.
  • mint: The creation of an amount of tokens. This MAY happen in a mint method or as a transfer from the zero address.
  • burn: The removal an amount of tokens. This MAY happen in a burn method or as a transfer to the zero address.

Methods

balanceOf

The total amount of a token id that an owner owns.

- name: balanceOf
  type: function
  stateMutability: view

  inputs:
    - name: owner
      type: address
    - name: id
      type: uint256

  outputs:
    - name: amount
      type: uint256

allowance

The total amount of a token id that a spender is permitted to transfer on behalf of an owner.

- name: allowance
  type: function
  stateMutability: view

  inputs:
    - name: owner
      type: address
    - name: spender
      type: address
    - name: id
      type: uint256

  outputs:
    - name: amount
      type: uint256

isOperator

Returns true if the spender is approved as an operator for an owner.

- name: isOperator
  type: function
  stateMutability: view

  inputs:
    - name: owner
      type: address
    - name: spender
      type: address

  outputs:
    - name: status
      type: bool

transfer

Transfers an amount of a token id from the caller to the receiver.

MUST revert when the caller’s balance for the token id is insufficient.

MUST log the Transfer event.

MUST return True.

- name: transfer
  type: function
  stateMutability: nonpayable

  inputs:
    - name: receiver
      type: address
    - name: id
      type: uint256
    - name: amount
      type: uint256

  outputs:
    - name: success
      type: bool

transferFrom

Transfers an amount of a token id from a sender to a receiver by the caller.

MUST revert when the caller is neither the sender nor an operator for the sender and the caller’s allowance for the token id for the sender is insufficient.

MUST revert when the sender’s balance for the token id is insufficient.

MUST log the Transfer event.

MUST decrease the caller’s allowance by the same amount of the sender’s balance decrease if the caller is not an operator for the sender and the caller’s allowance is not infinite.

SHOULD NOT decrease the caller’s allowance for the token id for the sender if the allowance is infinite.

SHOULD NOT decrease the caller’s allowance for the token id for the sender if the caller is an operator or the sender.

MUST return True.

- name: transferFrom
  type: function
  stateMutability: nonpayable

  inputs:
    - name: sender
      type: address
    - name: receiver
      type: address
    - name: id
      type: uint256
    - name: amount
      type: uint256

  outputs:
    - name: success
      type: bool

approve

Approves an amount of a token id that a spender is permitted to transfer on behalf of the caller.

MUST set the allowance of the spender of the token id for the caller to the amount.

MUST log the Approval event.

MUST return True.

- name: approve
  type: function
  stateMutability: nonpayable

  inputs:
    - name: spender
      type: address
    - name: id
      type: uint256
    - name: amount
      type: uint256

  outputs:
    - name: success
      type: bool

setOperator

Grants or revokes unlimited transfer permissions for a spender for any token id on behalf of the caller.

MUST set the operator status to the approved value.

MUST log the OperatorSet event.

MUST return True.

- name: setOperator
  type: function
  stateMutability: nonpayable

  inputs:
    - name: spender
      type: address
    - name: approved
      type: bool

  outputs:
    - name: success
      type: bool

Events

Transfer

The caller initiates a transfer of an amount of a token id from a sender to a receiver.

MUST be logged when an amount of a token id is transferred from one account to another.

MUST be logged with the sender address as the zero address when an amount of a token id is minted.

MUST be logged with the receiver address as the zero address when an amount of a token id is burned.

- name: Transfer
  type: event

  inputs:
    - name: caller
      indexed: false
      type: address
    - name: sender
      indexed: true
      type: address
    - name: receiver
      indexed: true
      type: address
    - name: id
      indexed: true
      type: uint256
    - name: amount
      indexed: false
      type: uint256

OperatorSet

The owner has set the approved status to a spender.

MUST be logged when the operator status is set.

MAY be logged when the operator status is set to the same status it was before the current call.

- name: OperatorSet
  type: event

  inputs:
    - name: owner
      indexed: true
      type: address
    - name: spender
      indexed: true
      type: address
    - name: approved
      indexed: false
      type: bool

Approval

The owner has approved a spender to transfer an amount of a token id to be transferred on the owner’s behalf.

MUST be logged when the allowance is set by an owner.

- name: Approval
  type: event

  inputs:
    - name: owner
      indexed: true
      type: address
    - name: spender
      indexed: true
      type: address
    - name: id
      indexed: true
      type: uint256
    - name: amount
      indexed: false
      type: uint256

Interface ID

The interface ID is 0x0f632fb3.

Metadata Extension

Methods

name

The name for a token id.

- name: name
  type: function
  stateMutability: view

  inputs:
    - name: id
      type: uint256

  outputs:
    - name: name
      type: string
symbol

The ticker symbol for a token id.

- name: symbol
  type: function
  stateMutability: view

  inputs:
    - name: id
      type: uint256

  outputs:
    - name: symbol
      type: string
decimals

The amount of decimals for a token id.

- name: decimals
  type: function
  stateMutability: view

  inputs:
    - name: id
      type: uint256

  outputs:
  - name: amount
    type: uint8

Content URI Extension

Methods

contractURI

The URI for the contract.

- name: contractURI
  type: function
  stateMutability: view

  inputs: []

  outputs:
    - name: uri
      type: string
tokenURI

The URI for a token id.

MAY revert if the token id does not exist.

MUST replace occurrences of {id} in the returned URI string by the client.

- name: tokenURI
  type: function
  stateMutability: view

  inputs:
    - name: id
      type: uint256

  outputs:
    - name: uri
      type: string

Metadata Structure

Contract URI

JSON Schema:

{
  "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"]
}

JSON Example (Minimal):

{
  "name": "Example Contract Name",
}
Token URI

MUST replace occurrences of {id} in the returned URI string by the client.

JSON Schema:

{
  "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"]
}

JSON Example (Minimal):

{
  "name": "Example Token Name",
  "description": "Example Token Description",
  "image": "exampleurl/{id}"
}

Token Supply Extension

Methods

totalSupply

The totalSupply for a token id.

- name: totalSupply
  type: function
  stateMutability: view

  inputs:
    - name: id
      type: uint256

  outputs:
    - name: supply
      type: uint256

Rationale

Granular Approvals

While the “operator model” from the ERC-1155 standard allows an account to set another account as an operator, giving full permissions to transfer any amount of any token id on behalf of the owner, this may not always be the desired permission scheme. The “allowance model” from ERC-20 allows an account to set an explicit amount of the token that another account can spend on the owner’s behalf. This standard requires both be implemented, with the only modification being to the “allowance model” where the token id must be specified as well. This allows an account to grant specific approvals to specific token ids, infinite approvals to specific token ids, or infinite approvals to all token ids.

Removal of Batching

While batching operations is useful, its place should not be in the standard itself, but rather on a case-by-case basis. This allows for different tradeoffs to be made in terms of calldata layout, which may be especially useful for specific applications such as roll-ups that commit calldata to global storage.

Removal of Required Callbacks

Requiring callbacks unnecessarily encumbers implementors that either have no particular use case for callbacks or prefer a bespoke callback mechanism. Minimization of such requirements saves contract size, gas efficiency and complexity.

Removal of “Safe” Naming

The safeTransfer and safeTransferFrom naming conventions are misleading, especially in the context of the ERC-1155 and ERC-721 standards, as they require external calls to receiver accounts with code, passing the execution flow to an arbitrary contract, provided the receiver contract returns a specific value. The combination of removing mandatory callbacks and removing the word “safe” from all method names improves the safety of the control flow by default.

Backwards Compatibility

This is not backwards compatible with ERC-1155 as some methods are removed. However, wrappers can be implemented for the ERC-20, ERC-721, and ERC-1155 standards.

Reference Implementation

// SPDX-License-Identifier: CC0-1.0
pragma solidity 0.8.19;

/// @title ERC6909 Multi-Token Reference Implementation
/// @author jtriley.eth
contract ERC6909 {
    /// @dev Thrown when owner balance for id is insufficient.
    /// @param owner The address of the owner.
    /// @param id The id of the token.
    error InsufficientBalance(address owner, uint256 id);

    /// @dev Thrown when spender allowance for id is insufficient.
    /// @param spender The address of the spender.
    /// @param id The id of the token.
    error InsufficientPermission(address spender, uint256 id);

    /// @notice The event emitted when a transfer occurs.
    /// @param sender The address of the sender.
    /// @param receiver The address of the receiver.
    /// @param id The id of the token.
    /// @param amount The amount of the token.
    event Transfer(address caller, address indexed sender, address indexed receiver, uint256 indexed id, uint256 amount);

    /// @notice The event emitted when an operator is set.
    /// @param owner The address of the owner.
    /// @param spender The address of the spender.
    /// @param approved The approval status.
    event OperatorSet(address indexed owner, address indexed spender, bool approved);

    /// @notice The event emitted when an approval occurs.
    /// @param owner The address of the owner.
    /// @param spender The address of the spender.
    /// @param id The id of the token.
    /// @param amount The amount of the token.
    event Approval(address indexed owner, address indexed spender, uint256 indexed id, uint256 amount);

    /// @notice Owner balance of an id.
    mapping(address owner => mapping(uint256 id => uint256 amount)) public balanceOf;

    /// @notice Spender allowance of an id.
    mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) public allowance;

    /// @notice Checks if a spender is approved by an owner as an operator.
    mapping(address owner => mapping(address spender => bool)) public isOperator;

    /// @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;
    }

    /// @notice Transfers an amount of an id from a sender to a receiver.
    /// @param sender The address of the sender.
    /// @param receiver The address of the receiver.
    /// @param id The id of the token.
    /// @param amount The amount of the token.
    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 Approves an amount of an id to a spender.
    /// @param spender The address of the spender.
    /// @param id The id of the token.
    /// @param amount The amount of the token.
    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 Sets or removes a spender as an operator for the caller.
    /// @param spender The address of the spender.
    /// @param approved The approval status.
    function setOperator(address spender, bool approved) public returns (bool) {
        isOperator[msg.sender][spender] = approved;
        emit OperatorSet(msg.sender, spender, approved);
        return true;
    }

    /// @notice Checks if a contract implements an interface.
    /// @param interfaceId The interface identifier, as specified in ERC-165.
    /// @return supported True if the contract implements `interfaceId`.
    function supportsInterface(bytes4 interfaceId) public pure returns (bool supported) {
        return interfaceId == 0x0f632fb3 || interfaceId == 0x01ffc9a7;
    }

    function _mint(address receiver, uint256 id, uint256 amount) internal {
      // WARNING: important safety checks should precede calls to this method.
      balanceOf[receiver][id] += amount;
      emit Transfer(msg.sender, address(0), receiver, id, amount);
    }

    function _burn(address sender, uint256 id, uint256 amount) internal {
      // WARNING: important safety checks should precede calls to this method.
      balanceOf[sender][id] -= amount;
      emit Transfer(msg.sender, sender, address(0), id, amount);
    }
}

Security Considerations

Approvals and Operators

The specification includes two token transfer permission systems, the “allowance” and “operator” models. There are two security considerations in regards to delegating permission to transfer.

The first consideration is consistent with all delegated permission models. Any account with an allowance may transfer the full allowance for any reason at any time until the allowance is revoked. Any account with operator permissions may transfer any amount of any token id on behalf of the owner until the operator permission is revoked.

The second consideration is unique to systems with both delegated permission models. If an account has both operator permissions and an insufficient allowance for a given transfer, performing the allowance check before the operator check would result in a revert while performing the operator check before the allowance check would not. The specification intentionally leaves this unconstrained for cases where implementors may track allowances despite the operator status. Nonetheless, this is a notable consideration.

contract ERC6909OperatorPrecedence {
  // -- snip --

  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 ERC6909AllowancePrecedence {
  // -- snip --

  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
  }
}

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

JT Riley (@jtriley-eth), Dillon (@d1ll0n), Sara (@snreynolds), Vectorized (@Vectorized), Neodaoist (@neodaoist), "ERC-6909: Minimal Multi-Token Interface [DRAFT]," Ethereum Improvement Proposals, no. 6909, April 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6909.