はじめに

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

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

今回は一度だけでなく、さまざまな場面で物理的/デジタルでの引き換え可能なNFTを実装するERC6672について解説していきます。

以下の記事を参考に解説していきます。

概要

ERC721規格の拡張機能の提案です。

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

ERC721とは、一般的にNFTと呼ばれる肥大耐性トークンを作成する標準規格です。

この提案では、引き換え可能な複数のNFTを可能にします。 NFTの保持者が、そのNFTの所有権と資格を証明する機能を提供し、物理的またはデジタルなアイテムを受け取ることができるようにします。 これにより、さまざまな場面でNFTを用いて引き換えることができ、ブロックチェーン上にそのデータを記録することが可能になります。

他のERC721の拡張機能として「ERC721A」というものがあります。

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

動機

さまざまな場面で異なるキャンペーンやイベントに対して、引き換え可能なNFTを実現し、新たな商用ユースケースの可能性を開こうとしています。 また、1つのNFTにつき1回しか引換できないという制約を取り外すことができます。

ユースケースの1つとして、デジタルコンサートチケットが挙げられます。 ・オンラインコンサートへの参加権。 ・グッズ。 ・アーティストとの握手会やサイン会。 上記のように、1つのNFTでさまざまなものと引き換えができます。

仕様

引き換えとキャンセルの機能

引き換えを行う運営は引き換えなどのデータを更新するだけで、他のことを実行されるようにしたいため、redeem()関数とcancel()関数には_operatorというパラメータが存在しません。 msg.senderアドレスは_operatorとして使用しなければいけないです。

引き換えフラグのキーと値のペア

_operator、_tokenId、_redemptionIdの組み合わせは、既に特定のイベントでの引き換えにに使用されたか確認するために必要となります。 また、その値はisRedeemed()関数から取得できます。

この機能の実装にはERC6672とERC721のインターフェースを実装しなければなりません。

インターフェースについては以下の記事を参考にしてください。

ERC6672のインターフェース

javascript
pragma solidity ^0.8.16;

interface IERC6672 /* is IERC721 */ {
    event Redeem(
        address indexed _operator,
        uint256 indexed _tokenId,
        address redeemer,
        bytes32 _redemptionId,
        string _memo
    );

    event Cancel(
      address indexed _operator,
      uint256 indexed _tokenId,
      bytes32 _redemptionId,
      string _memo
    );

    function isRedeemed(address _operator, bytes32 _redemptionId, uint256 _tokenId) external view returns (bool);

    function getRedemptionIds(address _operator, uint256 _tokenId) external view returns (bytes32[]);

    function redeem(bytes32 _redemptionId, uint256 _tokenId, string _memo) external;

    function cancel(bytes32 _redemptionId, uint256 _tokenId, string _memo) external;
}

Redeem

何らかのイベントで、引き換えが実行された時にログに残されます。

Cancel

何らかのイベントで、引き換えがキャンセルされた時にログに残されます。

isRedeemed

NFTが既にイベントで引き換えられているか確認する関数。

引数
  • _operator
    • 運営のアドレス。
  • _redemptionId
    • イベントのID.
  • _tokenId
    • 確認したいNFTのトークンID。
戻り値

NFTが既にイベントで引き換えられているかどうか。

getRedemptionIds

特定のNFTに対して、所有者のアドレスによって引き換えが行われた履歴を一覧で取得する関数。

引数
  • _operator
    • 運営のアドレス。
  • _tokenId
    • 確認したいNFTのトークンID。
戻り値

所有者のアドレスによって引き換えが行われた履歴の配列。

redeem

特定のイベントでNFTを引き換える関数。

引数
  • _redemptionId
    • イベントのID。
  • _tokenId
    • 引き換えをするNFTのトークンID。
  • memo
    • メモ。

cancel

特定のイベントでのNFTを引き換えをキャンセルする関数。

  • _redemptionId
    • イベントのID。
  • _tokenId
    • 引き換えをキャンセルするNFTのトークンID。
  • memo
    • メモ。

メタデータ拡張

イベント(redemptions)のキーと値のペアは、operator-tokenId-redemptionIdとして標準化されます。

  • operator
    • 運営のアドレス。
  • tokenId
    • 引き換えたNFTのトークンID。
  • redemptionId
    • イベントのID。

引き換え状況

引き換えの状況は、`trueかfalse`だけでなくより細かくできます。 例えば、物理的な商品の引き換えの場合以下のようにできます。 

  • 引き換え済み。
  • 支払済み。
  • 発送中。

NFTの所有者やマーケットプレイスが理解できる文字列の列挙型を使用することが推奨されます。

引き換えの説明

以下のような引き換えについての詳細情報を提供するために使用するべきです。

  • コンサートチケットの情報。
  • アクションフィギュアの詳細な説明。

メタデータ拡張オプション機能です。 実装することで、作成したスマートコントラクトを使用してさまざまな情報を確認できるようになります。

Metadataの拡張コード

javascript
interface IERC6672Metadata /* is IERC721Metadata */ {
    function tokenURI(uint256 _tokenId) external view returns (string);
}

MetadataのJSONスキーマ

json
{
    "title": "Asset Metadata",
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "description": "Identifies the asset to which this NFT represents"
        },
        "description": {
            "type": "string",
            "description": "Describes the asset to which this NFT represents"
        },
        "image": {
            "type": "string",
            "description": "A URI pointing to a resource with mime type image/* representing the asset to which this NFT represents. Consider making any images at a width between 320 and 1080 pixels and aspect ratio between 1.91:1 and 4:5 inclusive."
        }
    },
    "redemptions": {
        "operator-tokenId-redemptionId": {
            "status": {
                "type": "string",
                "description": "The status of a redemption. Enum type can be used to represent the redemption status, such as redeemed, shipping, paid."
            },
            "description": {
                "type": "string",
                "description": "Describes the object that has been redeemed for an NFT, such as the name of an action figure series name or the color of the product."
            }
        }
    }
}

補足

_operator、_tokenId、_redemptionIdの組み合わせは、各引き換えトランザクションにおいてユニークであるため、キーとして選ばれています。

_operator

同じNFTを複数のイベントでの引き換えに使用したいと考える可能性があります。 あるNFTをEvent-XとEvent-Yのチケットの両方に対して引き換え可能であり、各イベントのチケットの引き換えは別々のアドレスによって処理されます。

_tokenId

同じ_operatorによって作成された複数の引き換え記録を、各NFT保有者が持つことになるため、トークンIDをキーの一つとして使用することが重要です。

_redemptionId

NFTの保有者は同じNFTを同じオペレーターに対して、複数のイベントで引き換えることが可能となります。 例えば、Operator-Xは2つのイベントを持っており、両方のイベントで物理的なフィギュアと引き換えることが可能であるとします。 NFTの保有者は、両方のイベントで引き換える資格があり、各引き換えは同じ_operatorと_tokenIdで記録されるが、_redemptionIdは異なります。

後方互換性

この規格はERC721と互換性があります。

参考実装

いかが参考実装になります。

javascript
// SPDX-License-Identifier: MIT
pragma solidity 0.8.16;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
import "./interfaces/IERC6672.sol";

abstract contract ERC6672 is ERC721, IERC6672 {
    using EnumerableSet for EnumerableSet.Bytes32Set;

    bytes4 public constant IERC6672_ID = type(IERC6672).interfaceId;

    mapping(address => mapping(uint256 => mapping(bytes32 => bool))) redemptionStatus;
    mapping(address => mapping(uint256 => mapping(bytes32 => string)))
        public memos;
    mapping(address => mapping(uint256 => EnumerableSet.Bytes32Set)) redemptions;

    constructor() ERC721("Multiple RedeemableNFT", "mrNFT") {}

    function isRedeemed(
        address _operator,
        bytes32 _redemptionId,
        uint256 _tokenId
    ) external view returns (bool) {
        return _isRedeemed(_operator, _redemptionId, _tokenId);
    }

    function getRedemptionIds(
        address _operator,
        uint256 _tokenId
    ) external view returns (bytes32[] memory) {
        require(
            redemptions[_operator][_tokenId].length() > 0,
            "ERC6672: token doesn't have any redemptions."
        );
        return redemptions[_operator][_tokenId].values();
    }

    function redeem(
        bytes32 _redemptionId,
        uint256 _tokenId,
        string memory _memo
    ) external {
        address _operator = msg.sender;
        require(
            !_isRedeemed(_operator, _redemptionId, _tokenId),
            "ERC6672: token already redeemed."
        );
        _update(_operator, _redemptionId, _tokenId, _memo, true);
        redemptions[_operator][_tokenId].add(_redemptionId);
        emit Redeem(
            _operator,
            _tokenId,
            ownerOf(_tokenId),
            _redemptionId,
            _memo
        );
    }

    function cancel(
        bytes32 _redemptionId,
        uint256 _tokenId,
        string memory _memo
    ) external {
        address _operator = msg.sender;
        require(
            _isRedeemed(_operator, _redemptionId, _tokenId),
            "ERC6672: token doesn't redeemed."
        );
        _update(_operator, _redemptionId, _tokenId, _memo, false);
        _removeRedemption(_operator, _redemptionId, _tokenId);
        emit Cancel(_operator, _tokenId, _redemptionId, _memo);
    }

    function _isRedeemed(
        address _operator,
        bytes32 _redemptionId,
        uint256 _tokenId
    ) internal view returns (bool) {
        require(_exists(_tokenId), "ERC6672: token doesn't exists.");
        return redemptionStatus[_operator][_tokenId][_redemptionId];
    }

    function _update(
        address _operator,
        bytes32 _redemptionId,
        uint256 _tokenId,
        string memory _memo,
        bool isRedeemed_
    ) internal {
        redemptionStatus[_operator][_tokenId][_redemptionId] = isRedeemed_;
        memos[_operator][_tokenId][_redemptionId] = _memo;
        if (isRedeemed_) {
            emit Redeem(
                _operator,
                _tokenId,
                ownerOf(_tokenId),
                _redemptionId,
                _memo
            );
        } else {
            emit Cancel(_operator, _tokenId, _redemptionId, _memo);
        }
    }

    function _removeRedemption(
        address _operator,
        bytes32 _redemptionId,
        uint256 _tokenId
    ) internal {
        redemptions[_operator][_tokenId].remove(_redemptionId);
    }

    function supportsInterface(
        bytes4 interfaceId
    ) public view virtual override(ERC721, IERC165) returns (bool) {
        return
            interfaceId == type(IERC721).interfaceId ||
            interfaceId == type(IERC6672).interfaceId ||
            super.supportsInterface(interfaceId);
    }
}

セキュリティ

認証されていないオペレータが、他のオペレータが管理している引き換えフラグにアクセスして、引き換えをキャンセルできてしまうことがあります。 ERC6672の実装において、引き換えを作成したオペレータ(msg.senderを使用して識別)のみが、redeem()とcancel()関数を実行できるようにチェックが必要です。 また、redeem()とcancel()関数をERC721の送付承認機能と連携しないことが推奨されます。

最後に

今回の記事では、さまざまな場面で物理的/デジタルでの引き換え可能なNFTを実装するERC6672について解説してきました。 いかがだったでしょうか?

実行については今後追記していきたいと思います!

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

採用強化中!

CryptoGamesでは一緒に働く仲間を大募集中です。

この記事で書いた自分の経験からもわかるように、裁量権を持って働くことができて一気に成長できる環境です。 「ブロックチェーンやWeb3、NFTに興味がある」、「スマートコントラクトの開発に携わりたい」など、少しでも興味を持っている方はまずはお話ししましょう!