跳到主要内容

NFT盲盒实现方案

· 阅读需 13 分钟

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

假设有以下文件列表:

merkle-tree-001

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