NFT
の唯一性は、各トークンがスマートコントラクト内で tokenID
によって表されるためです。これにより、 tokenID
を通じてトークンの属性を設定でき、これらの属性は metadata json
として記述されます。以下のコードのように:
mapping(uint256 => string) private _tokenURIs;
_tokenURIs
の key
は tokenID
であり、 value
は metadata 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
を通じてファイルにアクセスできます。
同時にファイルの baseURL
( ipfs://{フォルダの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;
以下のファイルリストがあると仮定します:
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'));
}
}