Alert Source Discuss
Standards Track: ERC

ERC-7160: ERC-721 Multi-Metadata Extension

Multiple metadata URIs per token, with the option to pin a primary URI.

Authors 0xG (@0xGh), Marco Peyfuss (@mpeyfuss)
Created 2023-06-09
Requires EIP-165, EIP-721

Abstract

This EIP proposes an extension to the ERC-721 standard to support multiple metadata URIs per token. It introduces a new interface, IERC721MultiMetadata, which provides methods for accessing the metadata URIs associated with a token, including a pinned URI index and a list of all metadata URIs. The extension is designed to be backward compatible with existing ERC721Metadata implementations.

Motivation

The current ERC-721 standard allows for a single metadata URI per token with the ERC721Metadata implementation. However, there are use cases where multiple metadata URIs are desirable. Some example use cases are listed below:

  • A token represents a collection of (cycling) assets with individual metadata
  • An on-chain history of revisions to token metadata
  • Appending metadata with different aspect ratios so that it can be displayed properly on all screens
  • Dynamic and evolving metadata
  • Collaborative and multi-artist tokens

This extension enables such use cases by introducing the concept of multi-metadata support.

The primary reason for having a multi-metadata standard in addition to the existing ERC721Metadata standard is that dapps and marketplaces don’t have a mechanism to infer and display all the token URIs. Giving a standard way for marketplaces to offer collectors a way to pin/unpin one of the metadata choices also enables quick and easy adoption of this functionality.

Specification

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

The multi-metadata extension is OPTIONAL for ERC-721 contracts and it is RECOMMENDED to be used in conjunction with the ERC-4906 standard if implemented.

/// @title EIP-721 Multi-Metdata Extension
/// @dev The ERC-165 identifier for this interface is 0x06e1bc5b.
interface IERC7160 {

  /// @dev This event emits when a token uri is pinned and is
  ///  useful for indexing purposes.
  event TokenUriPinned(uint256 indexed tokenId, uint256 indexed index);

  /// @dev This event emits when a token uri is unpinned and is
  ///  useful for indexing purposes.
  event TokenUriUnpinned(uint256 indexed tokenId);

  /// @notice Get all token uris associated with a particular token
  /// @dev If a token uri is pinned, the index returned SHOULD be the index in the string array
  /// @dev This call MUST revert if the token does not exist
  /// @param tokenId The identifier for the nft
  /// @return index An unisgned integer that specifies which uri is pinned for a token (or the default uri if unpinned)
  /// @return uris A string array of all uris associated with a token
  /// @return pinned A boolean showing if the token has pinned metadata or not
  function tokenURIs(uint256 tokenId) external view returns (uint256 index, string[] memory uris, bool pinned);

  /// @notice Pin a specific token uri for a particular token
  /// @dev This call MUST revert if the token does not exist
  /// @dev This call MUST emit a `TokenUriPinned` event
  /// @dev This call MAY emit a `MetadataUpdate` event from ERC-4096
  /// @param tokenId The identifier of the nft
  /// @param index The index in the string array returned from the `tokenURIs` function that should be pinned for the token
  function pinTokenURI(uint256 tokenId, uint256 index) external;

  /// @notice Unpin metadata for a particular token
  /// @dev This call MUST revert if the token does not exist
  /// @dev This call MUST emit a `TokenUriUnpinned` event
  /// @dev This call MAY emit a `MetadataUpdate` event from ERC-4096
  /// @dev It is up to the developer to define what this function does and is intentionally left open-ended
  /// @param tokenId The identifier of the nft
  function unpinTokenURI(uint256 tokenId) external;

  /// @notice Check on-chain if a token id has a pinned uri or not
  /// @dev This call MUST revert if the token does not exist
  /// @dev Useful for on-chain mechanics that don't require the tokenURIs themselves
  /// @param tokenId The identifier of the nft
  /// @return pinned A bool specifying if a token has metadata pinned or not
  function hasPinnedTokenURI(uint256 tokenId) external view returns (bool pinned);
}

The TokenUriPinned event MUST be emitted when pinning a token uri with the pinTokenUri function.

The TokenUriUnpinned event MUST be emitted when unpinning a token uri with the unpinTokenUri function.

The tokenURI function defined in the ERC-721 Metadata extension MUST return the pinned URI when a token has a pinned uri.

The tokenURI function defined in the ERC-721 Metadata extension MUST return a default uri when a token has an unpinned uri.

The supportsInterface method MUST return true when called with 0x06e1bc5b.

Implementing functionality to add or remove uris to a token MUST be implemented separately from this standard. It is RECOMMENDED that one of the event defined in ERC-4906 are emitted whenever uris are added or removed.

See the Implementation section for an example.

Rationale

Similar terminology to ERC-721 was used in order to keep fetching metadata familiar. The concept of pinning and unpinning metadata is introduced as it is clear that NFT owners might want to choose which piece of metadata to display. At first, we considered leaving the pinning and unpinning actions up to each developer, but realized that a standard interface for pinning and unpinning allows for dApps to easily implement universal support for multi-metadata tokens.

We first considered whether the tokenURIs function should return just a string array, but added the extra information so that you could get all info desired in one call instead of potentially three calls. The pinned URI should be used as the primary URI for the token, while the list of metadata URIs can be used to access individual assets’ metadata within the token. dApps could present these as a gallery or media carousels.

The TokenUriPinned and TokenUriUnpinned events included in this specification can be used by dApps to index what metadata to show. This can eliminate on-chain calls and event driven architecture can be used instead.

The reason why this standard recommends the use of ERC-4906 when adding or removing uris from a token is that there is already wide dApp support for this event and it already is what is needed - an alert to dApps that metadata for a token has been updated. We did not want to potentially cause dApp issues with duplicate events. A third party listening to this event could then call the tokenURIs function to get the updated metadata.

Backwards Compatibility

This extension is designed to be backward compatible with existing ERC-721 contracts. The implementation of the tokenURI method must either return the pinned token uri (if pinned) or some default uri (if unpinned).

Reference Implementation

An open-source reference implementation of the IERC721MultiMetadata interface can be provided, demonstrating how to extend an existing ERC-721 contract to support multi-metadata functionality. This reference implementation can serve as a guide for developers looking to implement the extension in their own contracts.

// SPDX-License-Identifier: CC0-1.0

pragma solidity ^0.8.19;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {IERC4906} from "@openzeppelin/contracts/interfaces/IERC4906.sol";
import {IERC7160} from "./IERC7160.sol";

contract MultiMetadata is ERC721, Ownable, IERC7160, IERC4906 {
  mapping(uint256 => string[]) private _tokenURIs;
  mapping(uint256 => uint256) private _pinnedURIIndices;
  mapping(uint256 => bool) private _hasPinnedTokenURI;

  constructor(string memory _name, string memory _symbol) ERC721(_name, _symbol) Ownable() {
    _mint(msg.sender, 1);
  }

  // @notice Returns the pinned URI index or the last token URI index (length - 1).
  function _getTokenURIIndex(uint256 tokenId) internal view returns (uint256) {
    return _hasPinnedTokenURI[tokenId] ? _pinnedURIIndices[tokenId] : _tokenURIs[tokenId].length - 1;
  }

  // @notice Implementation of ERC721.tokenURI for backwards compatibility.
  // @inheritdoc ERC721.tokenURI
  function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
    _requireMinted(tokenId);

    uint256 index = _getTokenURIIndex(tokenId);
    string[] memory uris = _tokenURIs[tokenId];
    string memory uri = uris[index];

    // Revert if no URI is found for the token.
    require(bytes(uri).length > 0, "ERC721: not URI found");
    return uri;
  }

  /// @inheritdoc IERC721MultiMetadata.tokenURIs
  function tokenURIs(uint256 tokenId) external view returns (uint256 index, string[] memory uris, bool pinned) {
    _requireMinted(tokenId);
    return (_getTokenURIIndex(tokenId), _tokenURIs[tokenId], _hasPinnedTokenURI[tokenId]);
  }

  /// @inheritdoc IERC721MultiMetadata.pinTokenURI
  function pinTokenURI(uint256 tokenId, uint256 index) external {
    require(msg.sender == ownerOf(tokenId), "Unauthorized");
    _pinnedURIIndices[tokenId] = index;
    _hasPinnedTokenURI[tokenId] = true;
    emit TokenUriPinned(tokenId, index);
  }

  /// @inheritdoc IERC721MultiMetadata.unpinTokenURI
  function unpinTokenURI(uint256 tokenId) external {
    require(msg.sender == ownerOf(tokenId), "Unauthorized");
    _pinnedURIIndices[tokenId] = 0;
    _hasPinnedTokenURI[tokenId] = false;
    emit TokenUriUnpinned(tokenId);
  }

  /// @inheritdoc IERC721MultiMetadata.hasPinnedTokenURI
  function hasPinnedTokenURI(uint256 tokenId) external view returns (bool pinned) {
    return _hasPinnedTokenURI[tokenId];
  }

  /// @notice Sets a specific metadata URI for a token at the given index.
  function setUri(uint256 tokenId, uint256 index, string calldata uri) external onlyOwner {
    if (_tokenURIs[tokenId].length > index) {
      _tokenURIs[tokenId][index] = uri;
    } else {
      _tokenURIs[tokenId].push(uri);
    }

    emit MetadataUpdate(tokenId);
  }

  // Overrides supportsInterface to include IERC721MultiMetadata interface support.
  function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC721) returns (bool) {
    return (
      interfaceId == type(IERC7160).interfaceId ||
      super.supportsInterface(interfaceId)
    );
  }
}

Security Considerations

Care should be taken when specifying access controls for state changing events, such as those that allow uris to be added to tokens and those specified in this standard: the pinTokenUri and unpinTokenUri functions. This is up to the developers to specify as each application may have different requirements to allow for pinning and unpinning.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

0xG (@0xGh), Marco Peyfuss (@mpeyfuss), "ERC-7160: ERC-721 Multi-Metadata Extension," Ethereum Improvement Proposals, no. 7160, June 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7160.