Alert Source Discuss
📢 Last Call Standards Track: ERC

ERC-5496: 多权限管理 NFT 扩展

为 EIP-721 创建可共享的多权限 NFT

Authors Jeremy Z (@wnft)
Created 2022-07-30
Last Call Deadline 2022-11-29
Requires EIP-721

摘要

此 EIP 定义了一个扩展 EIP-721 的接口,为 NFT 提供可共享的多重权限。权限可以是链上的(投票权、领取空投的权限)或链下的(在线商店的优惠券、当地餐馆的折扣、进入机场贵宾休息室的权限)。每个 NFT 可以包含多个权限,并且权限的持有者可以可验证地将该权限转移给其他人。权限可以是不可共享的或可共享的。可共享的权限可以被克隆,提供者可以根据传播路径调整详细信息。还可以为每个权限设置过期时间。

动机

此标准旨在实时高效地管理附加到 NFT 的权限。许多 NFT 除了用作个人资料图片或艺术收藏品之外,还具有其他功能,它们可能在不同的场景中具有真正的效用。例如,一家时装店可能会给自己的 NFT 持有者提供折扣;DAO 成员 NFT 持有者可以投票决定如何使用他们的资金库;一个 dApp 可能会创建一个空投活动,以吸引特定人群(如一些蓝筹 NFT 持有者)来领取;杂货店可以发行其链上会员卡(作为 NFT),并在会员在杂货店购物时给予某些权限等。在某些情况下,拥有 NFT 的人不一定想使用他们的权限。通过提供附加数据记录 NFT 集合拥有的不同权限以及管理它们的接口,用户可以在不失去 NFT 所有权的情况下转移或出售权限。

EIP-721 仅记录所有权及其转移,NFT 的权限不会记录在链上。此扩展将允许商家/项目向特定人群提供特定权限,并且权限所有者可以独立管理每个权限。这为 NFT 具有真正的实用性提供了巨大的可能性。

例如,一家航空公司向 Crypto Punk 持有者发行一系列 EIP-721/EIP-1155 代币以赋予他们权限,以吸引他们加入他们的俱乐部。但是,由于这些代币未绑定到原始 NFT,如果原始 NFT 被转移,这些权限仍保留在原始持有者手中,并且新持有者无法自动享受这些权限。 因此,我们提出了一组可以将权限绑定到底层 NFT 的接口,同时允许用户独立管理这些权限。

规范

本文档中的关键词“必须 (MUST)”、“禁止 (MUST NOT)”、“必需 (REQUIRED)”、“应该 (SHALL)”、“不应该 (SHALL NOT)”、“应当 (SHOULD)”、“不应当 (SHOULD NOT)”、“建议 (RECOMMENDED)”、“可以 (MAY)”和“可选 (OPTIONAL)”应按照 RFC 2119 中的描述进行解释。

每个符合此标准的合约都必须实现 IERC5496 接口。可共享的多权限扩展对于 EIP-721 合约是可选的 (OPTIONAL)。

/// @title EIP-721 的多权限扩展
///  注意:此接口的 EIP-165 标识符为 0x076e1bbb
interface IERC5496{
    /// @notice 当 `owner` 更改 NFT 的 `权限持有者` 时发出。
    event PrivilegeAssigned(uint256 tokenId, uint256 privilegeId, address user, uint256 expires);
    /// @notice 当 `合约所有者` 更改集合的 `总权限` 时发出
    event PrivilegeTotalChanged(uint256 newTotal, uint256 oldTotal);

    /// @notice 设置 NFT 的权限持有者。
    /// @dev 到期时间应小于 30 天
    /// 如果 `msg.sender` 未被批准或不是 tokenId 的所有者,则抛出异常。
    /// @param tokenId 要设置权限的 NFT
    /// @param privilegeId 要设置的权限
    /// @param user 要设置的权限持有者
    /// @param expires 权限持有者可以拥有的时间
    function setPrivilege(uint256 tokenId, uint256 privilegeId, address user, uint256 expires) external;

    /// @notice 返回权限的到期时间戳
    /// @param tokenId 查询的 NFT 的标识符
    /// @param privilegeId 查询的权限的标识符
    /// @return 用户是否拥有特定权限
    function privilegeExpires(uint256 tokenId, uint256 privilegeId) external view returns(uint256);

    /// @notice 检查用户是否拥有特定权限
    /// @param tokenId 查询的 NFT 的标识符
    /// @param privilegeId 查询的权限的标识符
    /// @param user 查询的用户的地址
    /// @return 用户是否拥有特定权限
    function hasPrivilege(uint256 tokenId, uint256 privilegeId, address user) external view returns(bool);
}

每个实现此标准的合约都应该 (SHOULD) 在设置任何权限之前设置最大权限数,privilegeId 绝不能 (MUST NOT) 大于最大权限数。

当调用 setPrivilege 时,必须 (MUST) 发出 PrivilegeAssigned 事件。

当集合的 总权限 发生更改时,必须 (MUST) 发出 PrivilegeTotalChanged 事件。

当使用 0x076e1bbb 调用 supportsInterface 方法时,必须 (MUST) 返回 true

/// @title 可克隆扩展 - EIP-721 的可选扩展
interface IERC721Cloneable {
    /// @notice 当设置 NFT 可克隆的 `权限` 时发出。
    event PrivilegeCloned(uint tokenId, uint privId, address from, address to);

    /// @notice 设置某个可克隆的权限
    /// @param tokenId 查询的 NFT 的标识符
    /// @param privilegeId 查询的权限的标识符
    /// @param referrer 推荐人的地址
    /// @return 操作是否成功
    function clonePrivilege(uint tokenId, uint privId, address referrer) external returns (bool);
}

当调用 clonePrivilege 时,必须 (MUST) 发出 PrivilegeCloned 事件。

对于兼容的合约,建议 (RECOMMENDED) 使用 EIP-1271 来验证签名。

原理

可共享的权限

如果权限不可共享,则权限持有者的数量受 NFT 数量的限制。可共享的权限意味着原始权限持有者可以复制该权限并将其提供给其他人,而不是将其自己的权限转移给他们。这种机制极大地增强了权限的传播以及 NFT 的采用。

过期日期类型

权限的到期时间戳是一个时间戳,并存储在 uint256 类型的变量中。

推荐人的受益人

例如,一家当地的披萨店提供 30% 的折扣券,并且该店的老板鼓励他们的消费者与朋友分享该优惠券,然后朋友就可以获得该优惠券。假设 Tom 从商店获得了 30% 的折扣券,并且他与 Alice 分享了该优惠券。Alice 也获得了该优惠券,并且 Alice 的推荐人是 Tom。在某些特定情况下,Tom 可能会从商店获得更多奖励。这将有助于商家在消费者中传播促销活动。

提案:NFT 转移

如果 NFT 的所有者将所有权转移给另一个用户,则对“权限”没有任何影响。但是,如果所有者尝试通过 unwrap() 从包装的 NFT 中提取原始 EIP-721 代币,并且任何可用的权限仍在进行中,则可能会发生错误。我们保护权限持有者检查权限的最后到期日期的权利。

function unwrap(uint256 tokenId, address to) external {
    require(getBlockTimestamp() >= privilegeBook[tokenId].lastExpiresAt, "privilege not yet expired");

    require(ownerOf(tokenId) == msg.sender, "not owner");

    _burn(tokenId);

    IERC721(nft).transferFrom(address(this), to, tokenId);

    emit Unwrap(nft, tokenId, msg.sender, to);
}

向后兼容性

此 EIP 与任何遵循 EIP-721 标准的 NFT 兼容。它仅添加了更多功能和数据结构,而不会干扰原始的 EIP-721 标准。

测试用例

测试用例已通过参考实现来实现。

测试代码

test.js

在终端中运行:

truffle test ./test/test.js

testCloneable.js

在终端中运行:

truffle test ./test/testCloneable.js

参考实现

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0; 

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import "./IERC5496.sol";

contract ERC5496 is ERC721, IERC5496 {
    struct PrivilegeRecord {
        address user;
        uint256 expiresAt;
    }
    struct PrivilegeStorage {
        uint lastExpiresAt;
        // privId => PrivilegeRecord
        mapping(uint => PrivilegeRecord) privilegeEntry;
    }

    uint public privilegeTotal;
    // tokenId => PrivilegeStorage
    mapping(uint => PrivilegeStorage) public privilegeBook;
    mapping(address => mapping(address => bool)) private privilegeDelegator;

    constructor(string memory name_, string memory symbol_)
    ERC721(name_,symbol_)
    {
    
    }

    function setPrivilege(
        uint tokenId,
        uint privId,
        address user,
        uint64 expires
    ) external virtual {
        require((hasPrivilege(tokenId, privId, ownerOf(tokenId)) && _isApprovedOrOwner(msg.sender, tokenId)) || _isDelegatorOrHolder(msg.sender, tokenId, privId), "ERC721: transfer caller is not owner nor approved");
        require(expires < block.timestamp + 30 days, "expire time invalid");
        require(privId < privilegeTotal, "invalid privilege id");
        privilegeBook[tokenId].privilegeEntry[privId].user = user;
        if (_isApprovedOrOwner(msg.sender, tokenId)) {
            privilegeBook[tokenId].privilegeEntry[privId].expiresAt = expires;
            if (privilegeBook[tokenId].lastExpiresAt < expires) {
                privilegeBook[tokenId].lastExpiresAt = expires;
            }
        }
        emit PrivilegeAssigned(tokenId, privId, user, uint64(privilegeBook[tokenId].privilegeEntry[privId].expiresAt));
    }

    function hasPrivilege(
        uint256 tokenId,
        uint256 privId,
        address user
    ) public virtual view returns(bool) {
        if (privilegeBook[tokenId].privilegeEntry[privId].expiresAt >= block.timestamp){
            return privilegeBook[tokenId].privilegeEntry[privId].user == user;
        }
        return ownerOf(tokenId) == user;
    }

    function privilegeExpires(
        uint256 tokenId,
        uint256 privId
    ) public virtual view returns(uint256){
        return privilegeBook[tokenId].privilegeEntry[privId].expiresAt;
    }

    function _setPrivilegeTotal(
        uint total
    ) internal {
        emit PrivilegeTotalChanged(total, privilegeTotal);
        privilegeTotal = total;
    }

    function getPrivilegeInfo(uint tokenId, uint privId) external view returns(address user, uint256 expiresAt) {
        return (privilegeBook[tokenId].privilegeEntry[privId].user, privilegeBook[tokenId].privilegeEntry[privId].expiresAt);
    }

    function setDelegator(address delegator, bool enabled) external {
        privilegeDelegator[msg.sender][delegator] = enabled;
    }

    function _isDelegatorOrHolder(address delegator, uint256 tokenId, uint privId) internal virtual view returns (bool) {
        address holder = privilegeBook[tokenId].privilegeEntry[privId].user;
         return (delegator == holder || isApprovedForAll(holder, delegator) || privilegeDelegator[holder][delegator]);
    }

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

安全考虑

实现必须彻底考虑谁有权设置或克隆权限。

版权

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

Citation

Please cite this document as:

Jeremy Z (@wnft), "ERC-5496: 多权限管理 NFT 扩展 [DRAFT]," Ethereum Improvement Proposals, no. 5496, July 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5496.