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 服务获取随机数:在 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(n2)O(n^2) , 并且需求频频的删去元素,因而该办法不适用。

Knuth-Durstenfeld Shuffle

该算法的基本思想和 Fisher-Yates 相似,每次从未处理的数据中随机取出一个元素,然后把该元素与数组结尾的元素进行替换,即数组尾部存放的是现已取过的元素。

算法进程如下

  • 从原数组长度 n 中,随机生成一个索引 random
  • 从原数组中的 random个元素与最终一个元素交流,即数组的尾部放的是现已处理过的元素
  • 重复在 n-1 的长度中生成随机数 并与 倒数第二个元素进行交流

尽管该算法的时刻复杂度现已降低到O(n)O(n),但仍需频频的交流数组元素,形成额外的 gas。

为了避免频频的交流数组元素,能够用一个 mapping 来存储指向。

mapping(uint => uint) public referIdMap;

假设有以下文件列表:

NFT盲盒实现方案

开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'));
  }
}