moon

moon

Build for builders on blockchain
github
twitter

NFTブラインドボックス実現方案

NFT の唯一性は、各トークンがスマートコントラクト内で tokenID によって表されるためです。これにより、 tokenID を通じてトークンの属性を設定でき、これらの属性は metadata json として記述されます。以下のコードのように:

mapping(uint256 => string) private _tokenURIs;

_tokenURIskeytokenID であり、 valuemetadata json ファイルのアドレスです。

metadata json ファイルには、その NFT の画像リソースのアドレスなどの情報が保存されています。ブラインドボックスを実現するためには、ブラインドボックスを開く前にすべてのブラインドボックスにデフォルトの metadata json ファイルのアドレスを設定するか、設定しない必要があります。ブラインドボックスを開くときに具体的なファイルのアドレスを返します。

function tokenURI(uint256 tokenId) public view override returns (string memory) {
  // デフォルトのファイルアドレス
  if (!canOpen) return unrevealURI;
  // 具体的なファイルアドレス
  return _tokenURIs[tokenId];
}

方案一:直接設定#

プロジェクト側はブラインドボックスを開く前に、各 tokenID に対応する metadata json ファイルを準備します。ファイル名は ${tokenID}.json とし、これらのファイルをフォルダにまとめて ipfs に保存します。 ipfs://{フォルダのCID}/${tokenID}.json を通じてファイルにアクセスできます。

同時にファイルの baseURLipfs://{フォルダのCID}/)をスマートコントラクトに保存します。ブラインドボックスを開くときは、アドレスを直接結合することでブラインドボックスを開く目的を達成できます。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract GameItem is ERC721URIStorage {
  using Counters for Counters.Counter;
  
  // 自動増加するtokenId
  Counters.Counter private _tokenIds;
  
  // ブラインドボックスを開けるかどうか
  bool public canOpen = false;

  constructor() ERC721("GameItem", "ITM") {}

  function tokenURI(uint256 tokenId) public view override returns (string memory) {
    // ブラインドボックスを開けるかどうかを判断
    require(canOpen, '今は開けません');
    // 確実にミントされていることを確認
    require(_exists(tokenId), 'トークンはミントされていません');

    string memory baseURI = _baseURI();
    if (bytes(baseURI).length > 0) {
        // jsonファイルのアドレスを結合
        string memory path = string.concat(baseURI, Strings.toString(tokenId));
        return string.concat(path, '.json');
    } else {
        return '';
    }
  }
}

この方法は必要な gas が少ないですが、明らかな問題があります:プロジェクト側が開封後の画像が販売前に確定していることをどう証明するかです。例えば、プロジェクト側が 10 個のブラインドボックスを販売した場合、開封前にこれら 10 個のブラインドボックスの画像はすべて確定しているべきです。しかし、現在のこの方案では、ブラインドボックスが販売された後に開封されていない場合、プロジェクト側はファイルの内容を自由に入れ替えたり変更したりできます。例えば、3 号ブラインドボックスと 5 号ブラインドボックスの内容を入れ替えた場合、それぞれの tokenURI は変わりません。

もう一つの問題は、 nft の属性の対応関係が人為的に作られており、実際にはランダムではないことです。

方案二:ランダム数 + シャッフルアルゴリズム#

プロジェクト側が nft の設定ファイルを準備し、 ipfs にアップロードした場合、ブラインドボックスを開くときは、ファイルプールから重複しないファイルをランダムに取得するだけでランダム性の問題を解決できます。例えば、ユーザーが 1 号ブラインドボックスを開くとき、ランダムに対応する設定ファイルのアドレスは ipfs://{フォルダのCID}/5.json です。したがって、方案一のランダム性の問題をある程度解決できます。

次に baseURI の設定ですが、 ipfs の特性上、フォルダの CID はその内部ファイルによって決まります。一度内部ファイルが変更されると、フォルダの CID は必ず変わります。したがって、ファイル内容の変更を防ぐために、スマートコントラクトをデプロイする際に baseURI を設定する必要があり、これは変更できません。

方案二には解決すべき 2 つの問題があります:

  • ランダム数を取得する方法 - chainlink
  • 重複しないファイルを抽出する方法 - シャッフルアルゴリズム

ランダム数#

chainlink サービスを使用してランダム数を取得します:https://vrf.chain.link/ でサブスクリプションを作成すると、サブスクリプション IDが得られます。このサブスクリプションに link トークンをチャージします(ランダム数を取得するたびに LINK トークンを消費します)。最後にコントラクトアドレスをバインドします。

プロジェクトが hardhat フレームワークを使用している場合、 chainlink のコントラクトライブラリをインストールする必要があります。

$ yarn add @chainlink/contracts

ランダム数取得の例:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";

contract RoboNFT is VRFConsumerBaseV2 {
  // コーディネーター
  VRFCoordinatorV2Interface COORDINATOR;

  struct ChainlinkParams {
    // サブスクリプションID
    uint64 subId;
    // 使用するガスチャネル
    // 異なるネットワークのガスチャネル: https://docs.chain.link/docs/vrf-contracts/#configurations
    bytes32 keyHash;
    // コールバックのガス制限、取得するランダム数の数によって異なる
    // ランダム数を1つ取得するのに20000 weiが必要
    uint32 gasLimit;
    // リクエスト確認の回数 - 3に設定
    uint16 requestConfirms;
    // 各リクエストで取得するランダム数の数
    uint32 numWords;
  }
  ChainlinkParams public chainlinkParams;

  // 取得したランダム数を保存する配列
  uint256[] public randomNums;

  // _vrfCoordinator はコーディネーターのアドレスで、異なるネットワークのアドレスは https://docs.chain.link/docs/vrf-contracts/#configurations で確認
  constructor(
    ChainlinkParams memory _chainlinkParams,
    address _vrfCoordinator
  ) VRFConsumerBaseV2(_vrfCoordinator) {
    // コーディネーターコントラクトインスタンスを作成
    COORDINATOR = VRFCoordinatorV2Interface(_vrfCoordinator);
    // chainlinkパラメータを初期化
    chainlinkParams = _chainlinkParams;
  }

  // ランダム数をリクエスト(ウォレットに十分なLinkトークンが必要)
  function requestRandomWords() external {
    // コーディネーターを通じてランダム数をリクエストし、リクエストIDを返す
    uint requestId  = COORDINATOR.requestRandomWords(
      chainlinkParams.keyHash,
      chainlinkParams.subId,
      chainlinkParams.requestConfirms,
      chainlinkParams.gasLimit,
      chainlinkParams.numWords
    );
  }

  // chainlinkコールバック、リクエストIDとランダム数を渡す
  function fulfillRandomWords(
    uint256 requestId,
    uint256[] memory randomWords
  ) internal override {
  	// 取得したランダム数
    randomNums = randomWords;
  }
}

シャッフルアルゴリズム#

シャッフルアルゴリズムは、元の配列を散らばせ、元の配列の特定の数が散らばった後の配列の各位置に等確率で現れるようにします。 nft ブラインドボックスのシナリオでは、ファイルリストから等確率で重複しないファイルを取得することを意味します。

シャッフルアルゴリズムには以下の実装方法があります:

Fisher-Yates シャッフル#

  • 元の配列の長さから、ランダムにインデックス random を生成
  • 元の配列から random 番目の要素を削除し、その要素が選択された要素
  • シャッフルが終了するまで繰り返す

このアルゴリズムの時間計算量は $ O (n^2) $ であり、要素を頻繁に削除する必要があるため、この方法は適用できません。

Knuth-Durstenfeld シャッフル#

このアルゴリズムの基本的な考え方は Fisher-Yates と似ており、未処理のデータからランダムに 1 つの要素を選び、その要素を配列の末尾の要素と交換します。つまり、配列の末尾にはすでに選択された要素が格納されます。

アルゴリズムのプロセスは以下の通りです:

  • 元の配列の長さ n から、ランダムにインデックス random を生成
  • 元の配列の random 番目の要素と最後の要素を交換し、配列の末尾にはすでに処理された要素を置く
  • n-1 の長さでランダム数を生成し、倒数第二の要素と交換を繰り返す

このアルゴリズムの時間計算量は $O (n)$ に低下しましたが、依然として配列要素を頻繁に交換する必要があり、追加のガスが発生します。

配列要素の頻繁な交換を避けるために、 mapping を使用してポインタを保存できます。

mapping(uint => uint) public referIdMap;

以下のファイルリストがあると仮定します:
mystery-001.png

1 号ブラインドボックスを開くとき、ランダムに生成された [1-8] の間のランダム数は 4 です。この時 referIdMap[4] は指していないので、1 号ブラインドボックスに対応するファイルは 4 であり、同時に referIdMap[4] = 8 に設定します。

2 号ブラインドボックスを開くとき、ランダムに生成された [1-7] の間のランダム数は 4 です。この時 referIdMap[4] = 8 なので、2 号ブラインドボックスに対応するファイルは 8 であり、同時に referIdMap[4] = 7 に設定します。

3 号ブラインドボックスを開くとき、ランダムに生成された [1-6] の間のランダム数は 3 です。この時 referIdMap[3] は指していないので、3 号ブラインドボックスに対応するファイルは 3 であり、同時に referIdMap[3] = 6 に設定します。

このようにして、すべてのブラインドボックスを開くことができます。

ブラインドボックスの実装#

// SPDX-License-Identifier: MIT
// 資金調達のためにサブスクリプションに依存する消費者コントラクトの例。
pragma solidity ^0.8.7;

import '@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol';
import '@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol';
import '@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol';
import '@openzeppelin/contracts/utils/Counters.sol';
import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol';
import '@openzeppelin/contracts/utils/cryptography/MerkleProof.sol';

contract RoboNFT is ERC721URIStorage, VRFConsumerBaseV2, Ownable {
  using Counters for Counters.Counter;
  // 自動増加するtokenId
  Counters.Counter private _tokenIds;

  // コーディネーター - ランダム数をリクエストするため
  VRFCoordinatorV2Interface COORDINATOR;
  struct ChainlinkParams {
    bytes32 keyHash;
    uint64 subId;
    uint32 gasLimit;
    uint16 requestConfirms;
    uint32 numWords;
  }
  ChainlinkParams public chainlinkParams;

  string public baseURI; // すべてのnft属性ファイルがあるフォルダのipfsアドレス 形状は ipfs://{CID}/
  string public constant unrevealURI = 'ipfs://xxxxxx'; // ブラインドボックスのデフォルトのmetadata jsonアドレス

  // nft内部情報
  struct TokenInfo {
    bool requested; // ランダム数をリクエストしたか
    uint fileId; // ファイルid, 例えば10号ブラインドボックスに対応するfileIdが4であれば、返されるアドレスは baseURI + '4.json'
  }
  mapping(uint => TokenInfo) private tokenInfoMap; // tokenId => TokenInfo
  mapping(uint => uint) public vrfTokenIdMap; // requestId => tokenId
  mapping(uint => uint) public referIdMap; // ファイルプール内のファイルが使用されたかどうかを保存

  uint price = 0.01 ether; // mint価格
  bool public allowReveal = false; // ブラインドボックスを開けるかどうか
  uint public totalBoxes; // すべてのブラインドボックスの数
  uint perMaxMintCount = 5; // 各アドレスが最大でmintできる数
  uint public revealedCount; // 開封されたブラインドボックスの数

  // _vrfCoordinator はコーディネーターのアドレスで、異なるネットワークのアドレスは https://docs.chain.link/docs/vrf-contracts/#configurations で確認
  constructor(
    string memory _name,
    string memory _symbol,
    ChainlinkParams memory _chainlinkParams,
    address _vrfCoordinator,
    uint _totalBoxes,
    string memory _baseURI
  ) ERC721(_name, _symbol) VRFConsumerBaseV2(_vrfCoordinator) {
    // コーディネーターコントラクトインスタンスを作成
    COORDINATOR = VRFCoordinatorV2Interface(_vrfCoordinator);
    // chainlinkパラメータを初期化
    chainlinkParams = _chainlinkParams;
    // 総数を設定
    totalBoxes = _totalBoxes;
    // baseURIを設定
    baseURI = _baseURI;
    // tokenIdは1から開始
    _tokenIds.increment();
  }

  function setAllowReveal(bool _allowReveal) external onlyOwner {
    allowReveal = _allowReveal;
  }

  function withdraw(address payable _to) external payable onlyOwner {
    (bool success, ) = _to.call{value: address(this).balance}('');
    require(success);
  }

  // mint関数
  // _expireTime  mint終了時間
  function mint() external payable {
    // 残高 + 今回のmint数 <= 各アドレスが許可されている最大mint数
    require(balanceOf(msg.sender) + 1 <= perMaxMintCount, 'Mint数が超過しています');
    // 支払額がprice未満でないことを確認
    require(msg.value >= price, '0.01 etherが必要です');

    uint tokenId = _tokenIds.current();
    _safeMint(msg.sender, tokenId);
    _tokenIds.increment();
  }

  // ブラインドボックスを開くリクエスト
  function requestReveal(uint _tokenId) external {
    require(allowReveal, '今はボックスを開けられません'); // 現在ブラインドボックスを開けることが許可されていることを確認
    require(ownerOf(_tokenId) == msg.sender, 'そのnftはあなたのものではありません'); // 開けるnftがmsg.senderのものであることを確認
    require(!tokenInfoMap[_tokenId].requested, 'そのnftはランダム数をリクエストしました'); // _tokenIdがランダム数をリクエストしていないことを確認

    // ランダム数をリクエスト(ウォレットに十分なLinkトークンが必要)
    uint requestId = COORDINATOR.requestRandomWords(
      chainlinkParams.keyHash,
      chainlinkParams.subId,
      chainlinkParams.requestConfirms,
      chainlinkParams.gasLimit,
      chainlinkParams.numWords
    );
    tokenInfoMap[_tokenId].requested = true;

    // requestIdに対応するtokenIdを保存
    vrfTokenIdMap[requestId] = _tokenId;
  }

  // chainlinkコールバック、リクエストIDとランダム数を渡す
  function fulfillRandomWords(
    uint requestId,
    uint[] memory randomWords
  ) internal override {
    // tokenIdを取得
    uint tokenId = vrfTokenIdMap[requestId];
    // ランダム数
    uint random = randomWords[0];

    TokenInfo memory tokenInfo = tokenInfoMap[tokenId];

    // tokenIdがランダム数をリクエストしていて、ブラインドボックスIDが未設定の場合
    if (tokenInfo.requested && tokenInfo.fileId == 0) {
      uint remainCount = totalBoxes - revealedCount;
      // 残りのファイルプールからランダムに1つ取得(1〜remainCountの間のランダム数を生成)
      uint index = random % remainCount + 1;

      // ランダムなインデックスが以前にランダムに選ばれたかどうかを取得
      uint referId = referIdMap[index];

      if (referId > 0) {
        // インデックスが以前にランダムに選ばれた
        // 1. tokenIdに対応するファイルidをreferIdに設定
        // 2. referIdMap[index]を末尾の未使用要素に設定
        tokenInfo.fileId = referId;
        referIdMap[index] = remainCount;
      } else {
        // インデックスが以前にランダムに選ばれなかった
        // 1. tokenIdに対応するファイルidをindexに設定
        // 2. referIdMap[index]を末尾の未使用要素に設定
        tokenInfo.fileId = index;
        referIdMap[index] = remainCount;
      }
      // 開封されたブラインドボックス数 + 1
      revealedCount++;
    }
  }

  function tokenURI(uint _tokenId) public view virtual override returns(string memory) {
    require(_exists(_tokenId), 'トークンは存在しません');
    if (!allowReveal) return unrevealURI;

    uint fileId = tokenInfoMap[_tokenId].fileId;
    // ブラインドボックスが未開封
    if (fileId == 0) return unrevealURI;

    return string(abi.encodePacked(baseURI, Strings.toString(fileId), '.json'));
  }
}

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。