quentangle

Posted on Apr 08, 2022Read on Mirror.xyz

实时NFT:构建动态智能合约

翻译:团长(https://twitter.com/quentangle_

在本教程中,我们将构建一个动态 NFT。希望您对 NFT有所了解,知道一些流行项目,BAYC、Cool Cats 等。如果没有,您可能需要先查看关于从头启动 NFT 的文章。

背景

传统图像 ERC721 和 ERC1115 变体这样的 NFT 智能合约可以通过不同的方式存储图像信息:

  1. 合约存储了一个可以通过 tokenURI() 函数访问的 URI。这个 URI 指向像 IPFS 或 Arvweave 这样的去中心化存储位置,像 Opensea 的应用程序可以直接从源头查询数据。
  2. 合约通过 SVG直接生成图像,在链上构建元数据。这比简单地指向一些已经在链下生成元数据复杂一些。来自NounsDAO的Noun协议是这种类型的方法的优秀案例。

Figure 0. We Love the Nouns

在本教程中,我们将使用一种混合方法来生成元数据。元数据包含的图像将被存储在IPFS上,然而,JSON的生成将在链上完成。这使得动态创建元数据变得更加容易,同时让我们可以灵活地不费吹灰之力地改变元数据所包含的图像。

在继续之前,有必要注意一下元数据对NFT项目的影响。想象一下,通过操纵图像或甚至改变属性来改变像BAYC这样的蓝筹藏品的元数据,使一些token更加稀有。如果开发人员只需更新NFT的URI就能完全改变外观,那么收藏者就真的把所有的信任放在了团队的交付上。

这就是溯源验证的重要性,这是在NFTs中没有被充分提及的东西。如果NFTs只使用IPFS,开发者应该在他们披露后发布元数据文件夹的不可变的校验和(哈希)。这样一来,用户总是可以在链外验证元数据没有被篡改。如果元数据完全是在链上生成的,那么风险就比较小。

在动态NFT的情况下,元数据和图像可以改变,最终用户应该被告知事情将如何以及何时改变。在元数据位于链外(IPFS)的情况下,每次元数据变化时,可以将新的来源哈希值更新到合同上,这样就有了完整的可证明的审计跟踪。

实时NFTs ⏳

现在我们明白了NFT如何更新,如果我们可以使用真实世界的数据、事件,甚至是真正的随机性来动态更新我们的智能合约中的元数据,会怎么样呢?欢迎来到Chainlink的世界。

Chainlink是一个去中心化的预言机网络,它可以将链外数据输入到智能合约中,本质上赋予它们请求和响应任何链外API的能力。

Figure 1. Chainlink Network

本教程假定你已经有了MetaMask,并且熟悉改变网络的能力。我们将使用Rinkeby Testnet来进行所有的工作。

注意:如果你以前从未使用过测试网络,你可能需要启用这一功能。我的账户 > 设置 > 高级 > 显示测试网络。

接下来,我们需要用一些Rinkeby ETH和LINK来资助我们的钱包。每次调用Oracle将花费一些LINK,作为服务费给节点运营商。前往https://faucets.chain.link/rinkeby,在你的账户中填入一些测试ETH和LINK。

Figure 2. Chainlink测试币

构建时间 👷‍♂️

你可能想知道我们正在构建什么,以及将如何使用Chainlink Oracles来动态更新NFT。在Chainlink市场上有大量的数据提供商,但在本教程中,我们将使用Chainlink的VRF(可验证的随机函数)Oracle来在造币过程中随机分配NFTs。有两个关键部分使VRF独特而有趣。

  1. 随机性来自于一个真正的随机来源。以太坊区块链是完全确定的*,这意味着任何试图在智能合约内创造随机性的方法实际上都不是随机的,而是伪随机的,并且可以提前计算。
  2. 随机性是可验证的,意味着任何人都可以通过加密证明来验证随机性是如何产生的。关于这一点的更多信息,请查看Chainlink的这篇详细文章

* 区块链需要是确定性的,以便所有节点都能就每个区块达成共识,并就区块链的下一个状态达成一致。

有了这两个特点,我们就可以创建真正随机的NFT,并验证随机性的来源。完美!

Figure 4. Chainlink VRF

元数据 💾

在我们开始代码之前,与任何NFT一样,你需要一些描述NFT的元数据,并包含指向IPFS上的NFT图像的指针。由于我们要创建的NFT将指向随机图像,我们需要一种方法来保留元数据,如tokenId,同时仍然能够随机化图像指针。

为了达到这个目的,我们将在链上动态生成元数据,并将其作为base64编码的字符串返回。如果你不熟悉base64,它看起来像这样:

data:application/json;base64,eyJuYW1lIjoidG9rZW4gIyAwIiwgImRlc2NyaXB0aW9uIjoiQSBkeW5hbWljIE5GVCIsICJpbWFnZSI6ICJodHRwczovL2dhdGV3YXkucGluYXRhLmNsb3VkL2lwZnMvUW1YSDJlTmtIUzNXOFZveG5TcWhxWDVFSzE4cjkyNjg2a3VtS3VXM0dEeThVdi82LnBuZyJ9

浏览器能够自动解析这它,因此OpenSea仍然能够呈现NFT。把它扔进你的搜索栏,观察会发生什么……

你应该得到一些元数据,比如:

{"name":"token # 0", "description":"A dynamic NFT", "image": "https://gateway.pinata.cloud/ipfs/QmXH2eNkHS3W8VoxnSqhqX5EK18r92686kumKuW3GDy8Uv/6.png"}

在这个教程中,我从https://unsplash.com/找了10个图片,并通过piñata将它们存储到IPFS,这样我们就可以直接进入代码了。我们将使用的_baseTokenURI是:

https://gateway.pinata.cloud/ipfs/QmXH2eNkHS3W8VoxnSqhqX5EK18r92686kumKuW3GDy8Uv/

注意这只是指向图片,实际的元数据将在链上生成。你可以用你自己的10张图片来更新它。

Remix 💻

让我们在浏览器中打开一个新的Remix实例,创建一个名为dynamicNFT.sol的新文件。然后复制并粘贴以下代码到该文件中:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.1;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
import "base64-sol/base64.sol";

contract DynamicNFT is VRFConsumerBase, ERC721Enumerable, Ownable{
    using SafeMath for uint256;
    using Strings for uint256;
    using Strings for uint8;

    // VRF Variables
    bytes32 public keyHash;
    uint256 public  fee;
    uint256 public randomResult;

    // ERC721 Variables

    // Token Data
    uint256 public TOKEN_PRICE;
    uint256 public MAX_TOKENS;
    uint256 public MAX_MINTS;

    // Metadata
    string public _baseTokenURI;

    // Maps
    mapping(uint256 => uint256) public randomMap; // maps a tokenId to a random number
    mapping(bytes32 => uint256) public requestMap; // maps a requestId to a tokenId
    
    /**
     * Constructor inherits VRFConsumerBase
     * 
     * Network: Rinkeby
     * Chainlink VRF Coordinator address: 0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655B
     * LINK token address:                0x01BE23585060835E02B77ef475b0Cc51aA1e0709
     * Key Hash: 0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311
     */
    constructor(
        address _link,
        address _coordinator, 
        bytes32 _keyhash,
        uint256 _fee,
        string memory name, 
        string memory symbol, 
        string memory baseURI,
        uint256 tokenPrice,
        uint256 maxTokens,
        uint256 maxMints
    ) 
    VRFConsumerBase(_coordinator, _link)
    ERC721(name, symbol)
    {
        // Chainlink setters
        keyHash = _keyhash;
        fee = _fee;

        // ERC721 setters
        setTokenPrice(tokenPrice);
        setMaxTokens(maxTokens);
        setMaxMints(maxMints);
        setBaseURI(baseURI);
    }

    /* ========== ERC721 FUNCTIONS ========== */
    function setBaseURI(string memory baseURI) public onlyOwner {
        _baseTokenURI = baseURI;
    }

    function setMaxMints(uint256 maxMints_) public onlyOwner {
        MAX_MINTS = maxMints_;
    }

    function setTokenPrice(uint256 tokenPrice_) public onlyOwner {
        TOKEN_PRICE = tokenPrice_;
    }

    function setMaxTokens(uint256 maxTokens_) public onlyOwner {
        MAX_TOKENS = maxTokens_;
    }

    function _baseURI() internal view virtual override returns (string memory) {
        return _baseTokenURI;
    }

    function mintTokens(uint256 numberOfTokens) public payable {
        require(numberOfTokens <= MAX_MINTS, "Can only mint max purchase of tokens at a time");
        require(totalSupply().add(numberOfTokens) <= MAX_TOKENS, "Purchase would exceed max supply of Tokens");
        require(TOKEN_PRICE.mul(numberOfTokens) <= msg.value, "Ether value sent is not correct");

        for(uint256 i = 0; i < numberOfTokens; i++) {
            uint256 mintIndex = totalSupply();
            if (mintIndex < MAX_TOKENS) {
                _safeMint(msg.sender, mintIndex);

                // request a random number from VRF oracle
                bytes32 requestId = getRandomNumber();
                // map request to tokenId
                requestMap[requestId] = mintIndex;
            }
        }
    }

    function tokenURI(uint256 tokenId) public view override returns (string memory) {
        require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");

        // construct metdata from tokenId
        return constructTokenURI(tokenId);
    }

    function constructTokenURI(uint256 tokenId)
        public
        view
        returns (string memory)
    {
        // get random number from map
        uint256 randomNumber = randomMap[tokenId];
        // build tokenURI from randomNumber
        string memory randomTokenURI = string(abi.encodePacked(_baseTokenURI, randomNumber.toString(), ".png"));
        
        // metadata
        string memory name = string(abi.encodePacked("token #", tokenId.toString()));
        string memory description = "A dynamic NFT";

        // prettier-ignore
        return string(
            abi.encodePacked(
                'data:application/json;base64,',
                Base64.encode(
                    bytes(
                        abi.encodePacked('{"name":"', name, '", "description":"', description, '", "image": "', randomTokenURI, '"}')
                    )
                )
            )
        );
    }
    
    /** 
     * Requests randomness 
     */
    function getRandomNumber() public returns (bytes32 requestId) {
        require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK - fill contract with faucet");
        return requestRandomness(keyHash, fee);
    }

    /**
     * Callback function used by VRF Coordinator
     */
    function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
        randomResult = randomness;
        // constrain random number between 1-10
        uint256 modRandom = randomResult % 10 + 1;
        // get tokenId that created the request
        uint256 tokenId = requestMap[requestId];
        // store random result in token image map
        randomMap[tokenId] = modRandom;
    }
}

https://gist.githubusercontent.com/chadlohrli/b3ef1aec0c5823bfb93d4c1ff9e728f9/raw/65984212380fd9eacb2d038d92bdbbdeb1202527/dynamicNFT.sol

这段代码的一部分与《从零开始启动NFT》教程中的simpleNFT.sol相似。与Chainlink VRF功能有关的新内容,让我们深入了解一下:

第57行。用_coordinator地址和_link token地址初始化VRFConsumerBaseVRFConsumerBase用来发起Chainlink请求和回调。

第103–105行。从getRandomNumber()请求一个新的随机数,并将requestId映射到mintIndex(tokenId)。使用requestId是因为对VRF协调器的调用是异步的,可能以任何顺序返回,所以我们需要一种方法来跟踪哪个token发出了指定的请求。

第156–162行: fulfillRandomness是回调函数,Chainlink一旦有了随机数就会调用。下面几行是对1–10的随机数的约束,并将其存储到另一个映射randomMap中,该映射将tokenId映射到modRandom数中。

接下来的函数和代码负责元数据的链上生成:

第110–114行:tokenURI是OpenSea等市场用来提取NFT信息的方法。在第114行,元数据从辅助方法constructTokenURI中返回,该方法将tokenId作为参数。

第117–142行:constructTokenURI是神奇发生的地方。第123行使用tokenId来抓取存储在randomMap映射中的随机数。第125行通过连接_baseTokenURI和上面的随机数来构建完整的图像URI。

专业提示:在solidity中,你可以通过使用非常有用的abi.encodePacked()方法进行字符串插值。

第128–129行:这几行生成了将在元数据中使用的名称和符号。注意第128行,每次调用该方法时都会动态地生成名称。

第132–142行:这是构建元数据标准并转换为base64的地方。注意base64在对SVG信息进行编码时非常有效,但在我们的案例中,我们只有一个IPFS URI,所以存储收益并不是很大。

部署 🏗️

希望你对这个合约的工作原理有了更好的理解,并准备好部署它。

请到Remix左侧的第二个标签编译合约,确保使用的是solidity 0.8.1版本。在编译器配置下,你还需要启用优化并将运行次数设置为200次。优化可以减少字节码,但也有一些代价,请查看此链接以了解更多信息。

最后,确保你正在编译正确的合约:DynamicNFT (dynamicNFT.sol)

随着合约的编译和准备部署,我们需要在交易前传入一些构造参数。

确保你通过Injected Web3环境连接到Rinkeby(4)网络。以下是构造函数的参数。

  1. _link:0x01BE23585060835E02B77ef475b0Cc51aA1e0709 — 链接标记地址
  2. _coordinator: 0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655B — VRF预言机地址
  3. _keyHash: 0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311 — 用来生成随机性的
  4. _fee: 100000000000000000- 0.1 LINK
  5. name: My Dynamic NFT
  6. symbol: DNFT
  7. baseURI: https://gateway.pinata.cloud/ipfs/QmXH2eNkHS3W8VoxnSqhqX5EK18r92686kumKuW3GDy8Uv/ — 记住,斜杠很重要
  8. tokenPrice: 0 — 除非你是Rinkeby富豪
  9. maxTokens: 10 — 这是基于存储在baseURI文件夹中的图片数量。
  10. maxMints:1 — 一次可以铸造多少个

交互 🖱️

在Remix中复制了这个很长的参数列表,就该部署到Rinkeby Testnet上了。继续点击交易,等待合同被开采。追踪合约的地址,你可以在Remix的部署合约部分找到这个。

请记住,每一次调用Chainlink Oracle都要花费LINK,在VRF Oracle的情况下,这个数额是0.1 LINK。你甚至可以在第148行看到这个要求:

require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK - fill contract with faucet");

用LINK给合约打款 💸

我们可以用MetaMask将LINK直接发送到合约上。打开资产标签,找到LINK,然后发送1个LINK到你上面写下的合约地址。

铸币 🔮

准备铸造一些动态NFT。mintTokens方法是在你部署的合约下,输入1 ,然后进行交易!

如果你想的话,继续多做几次,这样你就可以在OpenSea上得到各种图像。请记住,每一个mint都会向Chainlink VRF Oracle发送请求,并且响应是异步处理的。这意味着随机数字可能需要一些时间来影响元数据,因此NFT图像可能不会立即显示出来。

OpenSea 🖼️

让我们看看OpenSea上显示了什么。你要使用https://testnets.opensea.io/,这是相当于OpenSea的Rinkeby,并键入你的ETH地址。

耶 🎉

祝贺你走到了最后! 在本教程中,你学到了:

  1. 如何使用Chainlink VRF来生成可证明的随机性
  2. 如何解释随机性以铸造NFTs
  3. 如何使用LINK代币来调用Chainlink合约
  4. Chainlink的请求/响应架构如何工作
  5. 如何在链上动态地生成元数据

使用的服务 🔨


原文:

https://medium.com/@ultrasoundchad/real-time-nfts-dynamic-smart-contracts-building-workshop-b2b62b526252

NFT