Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-7510: 跨合约分层 NFT

ERC-721 的一个扩展,用于维护来自不同合约的 token 之间的分层关系。

Authors Ming Jiang (@minkyn), Zheng Han (@hanbsd), Fan Yang (@fayang)
Created 2023-08-24
Discussion Link https://ethereum-magicians.org/t/eip-7510-cross-contract-hierarchical-nft/15687
Requires EIP-721

摘要

该标准是 ERC-721 的一个扩展。它提出了一种维护来自不同合约的 token 之间的分层关系的方法。该标准提供了一个接口来查询 NFT 的父 token,或者两个 NFT 之间是否存在父关系。

动机

某些 NFT 希望生成衍生资产作为新的 NFT。例如,一个 2D NFT 图像希望将其 3D 模型发布为新的衍生 NFT。一个 NFT 也可能来源于多个父 NFT。这种情况包括一个以来自其他 NFT 的多个角色为特色的电影 NFT。提出此标准是为了记录衍生 NFT 之间的这种分层关系。

现有的 ERC-6150 引入了类似的功能,但它只在同一合约中的 token 之间建立层次结构。更多时候,我们需要使用衍生 token 创建一个新的 NFT 集合,这需要建立跨合约关系。此外,从多个父级派生在 IP 许可的场景中非常常见,但现有标准也不支持这一点。

规范

Solidity 接口位于 IERC7510.sol

/// @notice 用于引用 NFT 合约中 token 的结构体
struct Token {
    address collection;
    uint256 id;
}

interface IERC7510 {

    /// @notice 当 NFT 的父 token 更新时发出
    event UpdateParentTokens(uint256 indexed tokenId);

    /// @notice 获取 NFT 的父 token
    /// @param tokenId 要获取父 token 的 NFT
    /// @return 此 NFT 的父 token 数组
    function parentTokensOf(uint256 tokenId) external view returns (Token[] memory);

    /// @notice 检查另一个 token 是否为 NFT 的父级
    /// @param tokenId 要检查其父级的 NFT
    /// @param otherToken 另一个要检查是否为父级的 token
    /// @return `otherToken` 是否为 `tokenId` 的父级
    function isParentToken(uint256 tokenId, Token memory otherToken) external view returns (bool);

    /// @notice 设置 NFT 的父 token
    /// @param tokenId 要设置父 token 的 NFT
    /// @param parentTokens 要设置的父 token
    function setParentTokens(uint256 tokenId, Token[] memory parentTokens) external;

}

原理

该标准与 ERC-6150 的主要区别在于两个方面:支持跨合约 token 引用,并允许多个父级。但我们尽量保持命名整体一致。

此外,我们在接口中没有包含 child 关系。原始 NFT 在其衍生 NFT 之前存在。因此,我们知道在铸造衍生 NFT 时要包含哪些父 token,但在铸造原始 NFT 时我们不知道子 token。如果必须记录子项,这意味着每当我们铸造一个衍生 NFT 时,我们需要调用它的原始 NFT 以将其添加为子项。但是,这两个 NFT 可能属于不同的合约,因此需要不同的写入权限,这使得实际上不可能将这两个操作合并到单个事务中。因此,我们决定仅从派生 NFT 记录 parent 关系。

向后兼容性

未发现向后兼容性问题。

测试用例

测试用例位于:ERC7510.test.ts:

import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { expect } from "chai";
import { ethers } from "hardhat";

const NAME = "NAME";
const SYMBOL = "SYMBOL";
const TOKEN_ID = 1234;

const PARENT_1_COLLECTION = "0xDEAdBEEf00000000000000000123456789ABCdeF";
const PARENT_1_ID = 8888;
const PARENT_1_TOKEN = { collection: PARENT_1_COLLECTION, id: PARENT_1_ID };

const PARENT_2_COLLECTION = "0xBaDc0ffEe0000000000000000123456789aBCDef";
const PARENT_2_ID = 9999;
const PARENT_2_TOKEN = { collection: PARENT_2_COLLECTION, id: PARENT_2_ID };

describe("ERC7510", function () {

  async function deployContractFixture() {
    const [deployer, owner] = await ethers.getSigners();

    const contract = await ethers.deployContract("ERC7510", [NAME, SYMBOL], deployer);
    await contract.mint(owner, TOKEN_ID);

    return { contract, owner };
  }

  describe("Functions", function () {
    it("Should not set parent tokens if not owner or approved", async function () {
      const { contract } = await loadFixture(deployContractFixture);

      await expect(contract.setParentTokens(TOKEN_ID, [PARENT_1_TOKEN]))
        .to.be.revertedWith("ERC7510: caller is not owner or approved");
    });

    it("Should correctly query token without parents", async function () {
      const { contract } = await loadFixture(deployContractFixture);

      expect(await contract.parentTokensOf(TOKEN_ID)).to.have.lengthOf(0);

      expect(await contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.equal(false);
    });

    it("Should set parent tokens and then update", async function () {
      const { contract, owner } = await loadFixture(deployContractFixture);

      await contract.connect(owner).setParentTokens(TOKEN_ID, [PARENT_1_TOKEN]);

      let parentTokens = await contract.parentTokensOf(TOKEN_ID);
      expect(parentTokens).to.have.lengthOf(1);
      expect(parentTokens[0].collection).to.equal(PARENT_1_COLLECTION);
      expect(parentTokens[0].id).to.equal(PARENT_1_ID);

      expect(await contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.equal(true);
      expect(await contract.isParentToken(TOKEN_ID, PARENT_2_TOKEN)).to.equal(false);

      await contract.connect(owner).setParentTokens(TOKEN_ID, [PARENT_2_TOKEN]);

      parentTokens = await contract.parentTokensOf(TOKEN_ID);
      expect(parentTokens).to.have.lengthOf(1);
      expect(parentTokens[0].collection).to.equal(PARENT_2_COLLECTION);
      expect(parentTokens[0].id).to.equal(PARENT_2_ID);

      expect(await contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.equal(false);
      expect(await contract.isParentToken(TOKEN_ID, PARENT_2_TOKEN)).to.equal(true);
    });

    it("Should burn and clear parent tokens", async function () {
      const { contract, owner } = await loadFixture(deployContractFixture);

      await contract.connect(owner).setParentTokens(TOKEN_ID, [PARENT_1_TOKEN, PARENT_2_TOKEN]);
      await contract.burn(TOKEN_ID);

      await expect(contract.parentTokensOf(TOKEN_ID)).to.be.revertedWith("ERC7510: query for nonexistent token");
      await expect(contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.be.revertedWith("ERC7510: query for nonexistent token");
      await expect(contract.isParentToken(TOKEN_ID, PARENT_2_TOKEN)).to.be.revertedWith("ERC7510: query for nonexistent token");

      await contract.mint(owner, TOKEN_ID);

      expect(await contract.parentTokensOf(TOKEN_ID)).to.have.lengthOf(0);
      expect(await contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.equal(false);
      expect(await contract.isParentToken(TOKEN_ID, PARENT_2_TOKEN)).to.equal(false);
    });
  });

  describe("Events", function () {
    it("Should emit event when set parent tokens", async function () {
      const { contract, owner } = await loadFixture(deployContractFixture);

      await expect(contract.connect(owner).setParentTokens(TOKEN_ID, [PARENT_1_TOKEN, PARENT_2_TOKEN]))
        .to.emit(contract, "UpdateParentTokens").withArgs(TOKEN_ID);
    });
  });

});

参考实现

参考实现位于:ERC7510.sol:

// SPDX-License-Identifier: CC0-1.0

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

import "./IERC7510.sol";

contract ERC7510 is ERC721, IERC7510 {

    mapping(uint256 => Token[]) private _parentTokens;
    mapping(uint256 => mapping(address => mapping(uint256 => bool))) private _isParentToken;

    constructor(
        string memory name, string memory symbol
    ) ERC721(name, symbol) {}

    function supportsInterface(
        bytes4 interfaceId
    ) public view virtual override returns (bool) {
        return interfaceId == type(IERC7510).interfaceId || super.supportsInterface(interfaceId);
    }

    function parentTokensOf(
        uint256 tokenId
    ) public view virtual override returns (Token[] memory) {
        require(_exists(tokenId), "ERC7510: query for nonexistent token");  // 需要_exists(tokenId),“ERC7510:查询不存在的 token”
        return _parentTokens[tokenId];
    }

    function isParentToken(
        uint256 tokenId, Token memory otherToken
    ) public view virtual override returns (bool) {
        require(_exists(tokenId), "ERC7510: query for nonexistent token");  // 需要_exists(tokenId),“ERC7510:查询不存在的 token”
        return _isParentToken[tokenId][otherToken.collection][otherToken.id];
    }

    function setParentTokens(
        uint256 tokenId, Token[] memory parentTokens
    ) public virtual override {
        require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC7510: caller is not owner or approved");  // 需要_isApprovedOrOwner(_msgSender(), tokenId),“ERC7510:调用者不是所有者或已批准”
        _clear(tokenId);
        for (uint256 i = 0; i < parentTokens.length; i++) {
            _parentTokens[tokenId].push(parentTokens[i]);
            _isParentToken[tokenId][parentTokens[i].collection][parentTokens[i].id] = true;
        }
        emit UpdateParentTokens(tokenId);
    }

    function _burn(
        uint256 tokenId
    ) internal virtual override {
        super._burn(tokenId);
        _clear(tokenId);
    }

    function _clear(
        uint256 tokenId
    ) private {
        Token[] storage parentTokens = _parentTokens[tokenId];
        for (uint256 i = 0; i < parentTokens.length; i++) {
            delete _isParentToken[tokenId][parentTokens[i].collection][parentTokens[i].id];
        }
        delete _parentTokens[tokenId];
    }

}

安全考虑

NFT 的父 token 可能由于两个原因指向无效数据。首先,父 token 可能会在以后被销毁。其次,实现 setParentTokens 的合约可能不会检查 parentTokens 参数的有效性。出于安全考虑,检索 NFT 的父 token 的应用程序需要验证它们是否存在为有效 token。

版权

版权和相关权利通过 CC0 放弃。

Citation

Please cite this document as:

Ming Jiang (@minkyn), Zheng Han (@hanbsd), Fan Yang (@fayang), "ERC-7510: 跨合约分层 NFT [DRAFT]," Ethereum Improvement Proposals, no. 7510, August 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7510.