はじめに

初めまして。 CryptoGamesというブロックチェーンゲーム企業でエンジニアをしている cardene(かるでね) です! スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

代表的なゲームはクリプトスペルズというブロックチェーンゲームです。

今回は、ERC721規格を拡張して、NFTに付与されている様々な特典をコントラクト内で管理し、NFTの売買のタイミングで柔軟な特典のやり取りを提案しているERC5496についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

ERC5496は現在(2023年9月3日)では「Last Call」段階です。

概要

このEIP(Ethereum Improvement Proposal)は、EIP721を拡張するインターフェースを定義しています。 この提案の目的は、NFTに新しい機能を追加することです。 NFTは独特なデジタルアセットを表すトークンで、この提案ではそれに「特権」を付与する方法を提案しています。

ERC721については以下を参考にしてください。

特権は、NFTの所有者に対して与えられる特別な権利や利益のことで、ブロックチェーン上で管理されるもの(オンチェーン)と、ブロックチェーン外のもの(オフチェーン)の両方があります。 例えば、投票権やエアドロップの請求権のようなものから、オンラインストアの割引や地元のレストランでの特典、空港のVIPラウンジへのアクセスなどが挙げられます。

1つのNFTには、複数の特権を付与することができます。 そして、特権の保持者はその特権を他の人に渡すことができ、その特権を本当に持っているかブロックチェーン上で証明できます。

特権は共有可能なものと、共有できないものの2つの種類があります。 共有可能な特権は複製でき、提供者は詳細を変更して特権を他の人に提供できます。 また、各特権には有効期限を設定することも可能です。

動機

この提案は、NFTに関連する特権を効率的にリアルタイムで管理するための標準を定めています。 NFTは、単にプロフィール画像やアートのコレクションとして使われるだけでなく、さまざまな場面で活用できます。 例えば、ファッションストアは自社のNFTを持つ人に割引を提供したり、DAOのメンバーNFT保持者はトレジャリーの使途についての提案に投票できます。 また、dAppは特定のグループ(たとえば、一部のブルーチップNFT保持者)向けにエアドロップイベントを開催し、特定の特権を渡すことができます。 同様に、食料品店はNFTを使ってメンバーシップカードを発行し、会員が店で買い物する際に特典を提供することも考えられます。

ただし、NFTを所有する人々は、特権を必ずしも利用したいとは限りません。 そこで、この提案では、異なる種類の特権を記録し、それらを効果的に管理するための方法を提供しています。 これにより、特権を他の人に譲渡したり販売したりすることが可能になり、NFTの所有権を失うことなく特権を活用できます。

EIP721はNFTの所有権と転送に関する情報を記録するもので、特権に関する情報はチェーン上に記録されていません。 この提案では、特権を特定のNFTに結び付ける方法を提供し、特権の所有者がそれぞれの特権を個別に管理できるようにします。 これにより、NFTが実際に価値のある機能を持つ可能性が大幅に広がります。

具体例として、航空会社がCrypto Punk保持者に特権を提供してクラブに参加を促すケースを考えてみましょう。 これらの特権は元のNFTと直接関連付けられていないため、元のNFTが転送されると特権は元の保持者のままで、新しい保持者は特権を自動的に受け取ることができません。 したがって、この提案では、特権を元のNFTに結び付けるための方法を提供し、同時にユーザーが特権を個別に管理できるようにしています。

仕様

この標準に準拠する全てのコントラクトはIERC5496インタフェースを実装する必要があります。 EIP721コントラクトでは、共有可能なマルチ特権拡張はオプションです。

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

この標準に従うすべてのコントラクトは、特権を設定する前に最大特権番号を設定します。 特権番号は最大特権番号を超えてはいけないです。

setPrivilegeメソッドが呼び出されたとき、PrivilegeAssignedイベントが発行されます。 これにより、特権が割り当てられたことがログとして記録されます。

また、特権の合計がコレクション内で変更された場合、PrivilegeTotalChangedイベントが発行されます。 これにより、特権の合計の変更が監視可能になります。

supportsInterfaceメソッドが0x076e1bbbという引数で呼び出された場合、trueを返す必要があります。 このメソッドは、特定のインターフェースをサポートしているかどうかを判定するためのものです。

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

clonePrivilegeメソッドが呼び出された際に、PrivilegeClonedイベントが発行されます。 このイベントは、特権が複製されたことをログとして記録します。

また、このコントラクトに準拠する場合、EIP1271を使用して署名を検証することが推奨されています。 EIP1271は、署名を有効かどうかを確認するための方法を提供するもので、コントラクトのセキュリティを強化するのに役立ちます。

補足

共有可能な特権

特権が共有可能でない場合、特権の数はNFTの数に限られます。 しかし、特権が共有可能な場合、元の特権保持者は特権を複製して他人に提供できます。 このとき、自分の特権を他人に譲渡するわけではなく、複製して共有するメカニズムです。 この方法により、特権が広まりやすくなり、NFTの普及も進みます。

有効期限のタイプ

特権の有効期限は、日時を示すタイムスタンプで表現されます。 このタイムスタンプはuint256型の変数に格納され、特権の有効期限を正確に管理するのに役立ちます。

紹介者の受益者

例えば、地元のピザ店が30%割引のクーポンを提供し、店主が顧客に友達と共有するように促しています。 友達はそのクーポンを受け取ることができます。 トムが店から30%割引クーポンを手に入れ、それをアリスと共有した場合を考えてみましょう。 アリスはクーポンを受け取り、そのクーポンを共有したトムが紹介者となります。 一部のケースでは、トムが店から追加の報酬を受けることもあります。 これにより、商店はプロモーションを顧客間で広める手助けを受けられます。

NFT Transferの提案

この提案では、NFTの所有者が別のユーザーに所有権を移譲した場合の動作について取り決めています。 この時、特権には影響がありませんが、wrapped NFTから元のEIP721トークンを取り出すためのunwrap()関数を使用しようとした時に、まだ有効な特権が存在する場合にエラーが発生する可能性があることに注意しています。 これにより、特権の保持者の権利を守るために、特権の有効期限を確認できる仕組みを導入しています。

具体的なコード例は以下になります。

solidity
// unwrap() 関数の定義
function unwrap(uint256 tokenId, address to) external {
    // 特権の最後の有効期限を過ぎていることを確認
    require(getBlockTimestamp() >= privilegeBook[tokenId].lastExpiresAt, "特権はまだ有効です");

    // トークンの所有者が呼び出し者であることを確認
    require(ownerOf(tokenId) == msg.sender, "所有者でないため操作できません");

    // トークンを燃やして(削除して)特権の取得を無効化
    _burn(tokenId);

    // NFTトークンを指定されたアドレスに移動
    IERC721(nft).transferFrom(address(this), to, tokenId);

    // Unwrapイベントを発行
    emit Unwrap(nft, tokenId, msg.sender, to);
}

後方互換性

提案されているこのEIPは、EIP721標準に従うあらゆる種類のNFTと互換性があります。 このEIPは、元のEIP721標準に干渉することなく、追加の機能とデータ構造を提供するだけです。

テスト

以下にまとめています。

参考実装

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

privilegeTotal

solidity
uint public privilegeTotal;

概要 特権の合計数を格納する変数。

詳細 特権の総数を示すために使用されます。 特権が追加されるたびに、この変数の値が更新されます。


privilegeBook

solidity
mapping(uint => PrivilegeStorage) public privilegeBook;

概要 トークンIDをキーとして、特権情報を格納するマッピング配列。

詳細 特定のトークンIDに関連する特権情報を管理します。 トークンIDごとに、PrivilegeStorage 構造体のインスタンスが格納されます。

パラメータ

  • uint
    • トークンID

privilegeDelegator

solidity
mapping(address => mapping(address => bool)) private privilegeDelegator;

概要 アドレス間の特権委任関係を管理するマッピング配列。

詳細 アドレス間の特権委任状態を保持します。 外部アドレスが別のアドレスに対して特権を委任したかどうかを示す真偽値が格納されます。

パラメータ

  • address
    • 委任元のアドレス。
  • address
    • 委任先のアドレス。

PrivilegeStorage

solidity
struct PrivilegeStorage {
    uint lastExpiresAt;
    mapping(uint => PrivilegeRecord) privilegeEntry;
}

概要 特権の格納と管理に使用される構造体。

パラメータ

  • lastExpiresAt
    • 最後に特権が有効期限切れになったタイムスタンプを格納する変数。
  • privilegeEntry
    • 特権IDをキーとして、PrivilegeRecord構造体のインスタンスを格納するマッピング配列。

PrivilegeRecord

solidity
struct PrivilegeRecord {
    address user;
    uint256 expiresAt;
}

概要 特権の詳細情報を格納する構造体。

パラメータ

  • user
    • 特権を所有するユーザーのアドレスを格納する変数。
  • expiresAt
    • 特権の有効期限のタイムスタンプを格納する変数。

constructor

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

概要 ERC721トークンのコントラクトのコンストラクタ。 トークンの名前とシンボルを設定して、親クラスのコンストラクタを呼び出します。

詳細 コントラクトがデプロイされる際に最初に実行される関数です。 引数としてトークンの名前とシンボルを受け取り、それらの値をERC721のコンストラクタに渡して初期化します。

引数

  • name_
    • トークンの名前を表す文字列。
  • symbol_
    • トークンのシンボルを表す文字列。

setPrivilege

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

概要 トークンに特権を割り当てる関数。

詳細 特権の割り当てに関する複数の条件をチェックしています。 所有者または承認済みのユーザーであるか、特権を保持するユーザーであるか、特定の条件を満たすことが必要です。 条件が満たされた場合、特権情報が更新されます。

引数

  • tokenId
    • 対象のトークンのID。
  • privId
    • 特権のID。
  • user
    • 特権を割り当てるユーザーのアドレス。
  • expires
    • 特権の有効期限(現在のタイムスタンプから30日以内)。

戻り値 なし


hasPrivilege

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

概要 指定されたトークンとユーザーが特定の特権を持っているかどうかを判定する関数。

詳細 特権が有効である場合、特権を持つユーザーが正しいかどうかを確認します。 有効期限が切れている場合、トークンの所有者が特権を持つかどうかを判定します。

引数

  • tokenId
    • 対象のトークンのID。
  • privId
    • 特権のID。
  • user
    • 特権を持つかどうかを確認するユーザーのアドレス。

戻り値

  • bool
    • 特権を持っている場合はtrue、そうでない場合はfalse。

privilegeExpires

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

概要 特定のトークンと特権の有効期限を返す関数。

引数

  • tokenId
    • 対象のトークンのID。
  • privId
    • 特権のID。

戻り値

  • uint256
    • 特権の有効期限のタイムスタンプ。

_setPrivilegeTotal

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

概要 特権の総数を設定する関数。

引数

  • total
    • 特権の総数。

戻り値 なし


getPrivilegeInfo

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

概要 特定のトークンと特権に関する情報を取得する関数。

引数

  • tokenId
    • 対象のトークンのID。
  • privId
    • 特権のID。

戻り値

  • user
    • 特権を持つユーザーのアドレス。
  • expiresAt
    • 特権の有効期限のタイムスタンプ。

setDelegator

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

概要 ユーザーが他のユーザーに対して特権の委任を設定する関数。

詳細 特権の委任を設定する関数であり、ユーザーが他のユーザーに対して特権を操作できるようにします。

引数

  • delegator
    • 特権を委任するユーザーのアドレス。
  • enabled
    • 委任を有効にするかどうかのフラグ。

戻り値 なし


_isDelegatorOrHolder

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

概要 特権を持つユーザーまたは委任者であるかどうかを判定する関数。

引数

  • delegator
    • 特権を委任するユーザーのアドレス。
  • tokenId
    • 対象のトークンのID。
  • privId
    • 特権のID。

戻り値

  • bool
    • 特権を持つか、または委任されている場合はtrue、そうでない場合はfalse。

supportsInterface

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

概要 指定されたインターフェースをサポートしているかどうかを判定する関数。

詳細 特定のインターフェースをサポートしているかどうかを判定する関数です。 IERC5496のインターフェースIDをサポートしている場合にtrueを返します。

引数

  • interfaceId
    • 判定するインターフェースのID。

戻り値

  • bool
    • 指定されたインターフェースをサポートしている場合はtrue、そうでない場合はfalse。

セキュリティ考慮事項

実装では、誰が特権を設定したりクローンしたりする権限を持っているかを徹底的に検討しなければならない。

引用

Jeremy Z (@wnft), "ERC-5496: Multi-privilege Management NFT Extension [DRAFT]," Ethereum Improvement Proposals, no. 5496, July 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5496.

最後に

今回は「ERC721規格を拡張して、NFTに付与されている様々な特典をコントラクト内で管理し、NFTの売買のタイミングで柔軟な特典のやり取りを提案しているERC5496」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!