Abstract

This EIP defines an interface extending EIP-721 to provide shareable multi-privileges for NFTs. Privileges may be on-chain (voting rights, permission to claim an airdrop) or off-chain (a coupon for an online store, a discount at a local restaurant, access to VIP lounges in airports). Each NFT may contain many privileges, and the holder of a privilege can verifiably transfer that privilege to others. Privileges may be non-shareable or shareable. Shareable privileges can be cloned, with the provider able to adjust the details according to the spreading path. Expiration periods can also be set for each privilege.

Motivation

This standard aims to efficiently manage privileges attached to NFTs in real-time. Many NFTs have functions other than just being used as profile pictures or art collections, they may have real utilities in different scenarios. For example, a fashion store may give a discount for its own NFT holders; a DAO member NFT holder can vote for the proposal of how to use their treasury; a dApp may create an airdrop event to attract a certain group of people like some blue chip NFT holders to claim; the grocery store can issue its membership card on chain (as an NFT) and give certain privileges when the members shop at grocery stores, etc. There are cases when people who own NFTs do not necessarily want to use their privileges. By providing additional data recording different privileges a NFT collection has and interfaces to manage them, users can transfer or sell privileges without losing their ownership of the NFT.

EIP-721 only records the ownership and its transfer, the privileges of an NFT are not recorded on-chain. This extension would allow merchants/projects to give out a certain privilege to a specified group of people, and owners of the privileges can manage each one of the privileges independently. This facilitates a great possibility for NFTs to have real usefulness.

For example, an airline company issues a series of EIP-721/EIP-1155 tokens to Crypto Punk holders to give them privileges, in order to attract them to join their club. However, since these tokens are not bound to the original NFT, if the original NFT is transferred, these privileges remain in the hands of the original holders, and the new holders cannot enjoy the privileges automatically. So, we propose a set of interfaces that can bind the privileges to the underlying NFT, while allowing users to manage the privileges independently.

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.

Every contract complying with this standard MUST implement the IERC5496 interface. The shareable multi-privilege extension is OPTIONAL for EIP-721 contracts.

/// @title multi-privilege extension for EIP-721
///  Note: the EIP-165 identifier for this interface is 0x076e1bbb
interface IERC5496{
    /// @notice Emitted when `owner` changes the `privilege holder` of a NFT.
    event PrivilegeAssigned(uint256 tokenId, uint256 privilegeId, address user, uint256 expires);
    /// @notice Emitted when `contract owner` changes the `total privilege` of the collection
    event PrivilegeTotalChanged(uint256 newTotal, uint256 oldTotal);

    /// @notice set the privilege holder of a NFT.
    /// @dev expires should be less than 30 days
    /// Throws if `msg.sender` is not approved or owner of the tokenId.
    /// @param tokenId The NFT to set privilege for
    /// @param privilegeId The privilege to set
    /// @param user The privilege holder to set
    /// @param expires For how long the privilege holder can have
    function setPrivilege(uint256 tokenId, uint256 privilegeId, address user, uint256 expires) external;

    /// @notice Return the expiry timestamp of a privilege
    /// @param tokenId The identifier of the queried NFT
    /// @param privilegeId The identifier of the queried privilege
    /// @return Whether a user has a certain privilege
    function privilegeExpires(uint256 tokenId, uint256 privilegeId) external view returns(uint256);

    /// @notice Check if a user has a certain privilege
    /// @param tokenId The identifier of the queried NFT
    /// @param privilegeId The identifier of the queried privilege
    /// @param user The address of the queried user
    /// @return Whether a user has a certain privilege
    function hasPrivilege(uint256 tokenId, uint256 privilegeId, address user) external view returns(bool);
}

Every contract implementing this standard SHOULD set a maximum privilege number before setting any privilege, the privilegeId MUST NOT be greater than the maximum privilege number.

The PrivilegeAssigned event MUST be emitted when setPrivilege is called.

The PrivilegeTotalChanged event MUST be emitted when the total privilege of the collection is changed.

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

/// @title Cloneable extension - Optional for EIP-721
interface IERC721Cloneable {
    /// @notice Emitted when set the `privilege ` of a NFT cloneable.
    event PrivilegeCloned(uint tokenId, uint privId, address from, address to);

    /// @notice set a certain privilege cloneable
    /// @param tokenId The identifier of the queried NFT
    /// @param privilegeId The identifier of the queried privilege
    /// @param referrer The address of the referrer
    /// @return Whether the operation is successful or not
    function clonePrivilege(uint tokenId, uint privId, address referrer) external returns (bool);
}

The PrivilegeCloned event MUST be emitted when clonePrivilege is called.

For Compliant contract, it is RECOMMENDED to use EIP-1271 to validate the signatures.

Rationale

Shareable Privileges

The number of privilege holders is limited by the number of NFTs if privileges are non-shareable. A shareable privilege means the original privilege holder can copy the privilege and give it to others, not transferring his/her own privilege to them. This mechanism greatly enhances the spread of privileges as well as the adoption of NFTs.

Expire Date Type

The expiry timestamp of a privilege is a timestamp and stored in uint256 typed variables.

Beneficiary of Referrer

For example, a local pizza shop offers a 30% off Coupon and the owner of the shop encourages their consumers to share the coupon with friends, then the friends can get the coupon. Let’s say Tom gets 30% off Coupon from the shop and he shares the coupon with Alice. Alice gets the coupon too and Alice’s referrer is Tom. For some certain cases, Tom may get more rewards from the shop. This will help the merchants in spreading the promotion among consumers.

Proposal: NFT Transfer

If the owner of the NFT transfers ownership to another user, there is no impact on “privileges”. But errors may occur if the owner tries to withdraw the original EIP-721 token from the wrapped NFT through unwrap() if any available privileges are still ongoing. We protect the rights of holders of the privileges to check the last expiration date of the privilege.

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

Backwards Compatibility

This EIP is compatible with any kind of NFTs that follow the EIP-721 standard. It only adds more functions and data structures without interfering with the original EIP-721 standard.

Test Cases

Test cases are implemented with the reference implementation.

Test Code

test.js

Run in terminal:

truffle test ./test/test.js

testCloneable.js

Run in terminal:

truffle test ./test/testCloneable.js

Reference Implementation

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

Security Considerations

Implementations must thoroughly consider who has the permission to set or clone privileges.

Copyright and related rights waived via CC0.