moon

moon

Build for builders on blockchain
github
twitter

NFT盲盒實現方案

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

mapping(uint256 => string) private _tokenURIs;

_tokenURIskey 就是 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, '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 服務獲取隨機數:在 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;
    // 要使用的 gas 通道
    // 不同網路的 gas 通道: https://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 是協調器地址,不同網路地址查看 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 Shuffle#

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

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

Knuth-Durstenfeld Shuffle#

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

算法過程如下

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

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

為了避免頻繁的交換數組元素,可以用一個 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
// 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 是協調器地址,不同網路地址查看 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 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'));
  }
}

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。