NFT盲盒實現方案

語言: CN / TW / HK

NFT 之所以具有唯一性,主要是因為每一枚代幣在智慧合約中都有一個 tokenID 來表示,以此便可以通過 tokenID 來對代幣進行屬性設定,這些屬性描述為 metadata json。如下程式碼所示:

solidity mapping(uint256 => string) private _tokenURIs;

_tokenURIskey 就是 tokenID, value 則是 metadata json 檔案的地址。

metadata json 檔案中儲存著該 NFT 的圖片資源地址等資訊,若要實現盲盒本質上就是在未開盲盒之前給所有的盲盒設定一個預設的 metadata json 檔案地址或不設定,開盲盒的時候再返回具體的檔案地址。

solidity 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}/)儲存到智慧合約中。開盲盒的時候直接對地址進行拼接就能達到開盲盒的目的。

```solidity // 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, 'can not open now'); // 確保已被 mint require(_exists(tokenId), 'token not minted');

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,並且其是不可修改的。

針對方案二有兩個問題需要解決:

  • 如何獲取隨機數 - chainlink
  • 如何不重複的抽取檔案 - 洗牌演算法

隨機數

使用 chainlink 服務獲取隨機數:在 http://vrf.chain.link/ 上建立訂閱會得到一個訂閱ID, 在此訂閱中充值 link 代幣(每次獲取隨機數都需要消耗LINK 代幣), 最後並繫結合約地址。

如果你的專案是使用 hardhat 框架,需要安裝 chainlink 的合約庫

bash $ yarn add @chainlink/contracts

獲取隨機數示例:

```solidity // 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; // 要使用的 gas 通道 // 不同網路的 gas 通道: http://docs.chain.link/docs/vrf-contracts/#configurations bytes32 keyHash; // 回撥的 gas 限制,其值取決於要獲取的隨機數的數量 // 獲取一個隨機數需要 20000 wei uint32 gasLimit; // 請求確認的次數 - 設定為3即可 uint16 requestConfirms; // 每次請求獲得的隨機數數量 uint32 numWords; } ChainlinkParams public chainlinkParams;

// 儲存返回的隨機數的陣列 uint256[] public randomNums;

// _vrfCoordinator 是協調器地址,不同網路地址檢視 http://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 Shuffle

  • 從原陣列長度中,隨機生成一個索引 random
  • 從原陣列中刪除第 random 個元素,第 random個元素就是選取的元素
  • 重複直到洗牌結束

由於該演算法的時間複雜度為$ O(n^2) $ , 並且需要頻繁的刪除元素,因此該方法不適用。

Knuth-Durstenfeld Shuffle

該演算法的基本思想和 Fisher-Yates 類似,每次從未處理的資料中隨機取出一個元素,然後把該元素與陣列末尾的元素進行替換,即陣列尾部存放的是已經取過的元素。

演算法過程如下

  • 從原陣列長度 n 中,隨機生成一個索引 random
  • 從原陣列中的 random個元素與最後一個元素交換,即陣列的尾部放的是已經處理過的元素
  • 重複在 n-1 的長度中生成隨機數 並與 倒數第二個元素進行交換

雖然該演算法的時間複雜度已經降低到$O(n)$,但仍需頻繁的交換陣列元素,造成額外的 gas。

為了避免頻繁的交換陣列元素,可以用一個 mapping 來儲存指向。

solidity 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;

以此類推,就能開完所有盲盒。

盲盒實現

```solidity // SPDX-License-Identifier: MIT // An example of a consumer contract that relies on a subscription for funding. 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 是協調器地址,不同網路地址檢視 http://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 number exceeds'); // 確保支付金額不小於 price require(msg.value >= price, 'require 0.01 ether');

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

}

// 請求開盲盒 function requestReveal(uint _tokenId) external { require(allowReveal, 'you can not open the box now'); // 確保當前允許開盲盒 require(ownerOf(_tokenId) == msg.sender, 'the nft does not belong to you'); // 確保要開的 nft 屬於 msg.sender require(!tokenInfoMap[_tokenId].requested, 'the nft has requested random number'); // 確保 _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 ~ remainCount 之間的隨機數)
  uint index = random % remainCount + 1;

  // 獲取隨機的 index 是否曾被隨機過
  uint referId = referIdMap[index];

  if (referId > 0) {
    // 曾隨機到 index
    // 1. 設定 tokenId 對應的檔案id是 referId
    // 2. 將 referIdMap[index] 設定為末尾未使用的元素 
    tokenInfo.fileId = referId;
    referIdMap[index] = remainCount;
  } else {
    // 未隨機到 index
    // 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), 'token not exist'); if (!allowReveal) return unrevealURI;

uint fileId = tokenInfoMap[_tokenId].fileId;
// 盲盒未開
if (fileId == 0) return unrevealURI;

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

} }

```

「其他文章」