# NFT开发入门教程

By [RuoYi](https://paragraph.com/@ruoyi) · 2022-05-21

---

1.环境准备
======

> 本文环境准备以 Mac OS 为例，在 version 12.0.1 下进行开发 本文教程基于[🔗 buildspace](https://buildspace.so/)的课程：Mint your own NFT collection and ship a Web3 app to show them off

1.1 本地开发环境准备
------------

`HardHat`是一个允许我们快速创建智能合约并在本地部署的工具，具体介绍见官网链接：[🔗 HardHat](https://hardhat.org/tutorial/setting-up-the-environment.html) 前置安装`brew` -> `Node/npm`，若没有`brew`请自行安装，安装好`brew`后可以通过`brew`直接安装`node`：

等待时间较长，可以通过🪜解决。 接下来准备本地文件夹，并初始化文件夹内环境：

    mkdir epic-nfts
    cd epic-nfts
    npm init -y
    npm install --save-dev hardhat
    

等待进度完成即可。

1.2 HardHat HelloWorld
----------------------

启动`HardHat`：

选择`Create a basic sample project`后一路回车确认，并等待其中自动触发的npm安装。 待命令行显示`✨ Project created ✨`说明project初始化成功。 之后安装`OpenZeppelin`，这是创建智能合约的另一个常用库。

    npm install @openzeppelin/contracts
    

安装完毕后启动`HardHat`：

    npx hardhat run scripts/sample-script.js
    

你会在命令行中看到：

    Downloading compiler 0.8.4
    Compiled 2 Solidity files successfully
    Deploying a Greeter with greeting: Hello, Hardhat!
    Greeter deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
    

如果看到了，则说明本地环境已建立，且已将智能合约运行在本地区块链上，其中智能合约在：

    less contracts/Greeter.sol
    

而该合约的执行脚本在：

    less scripts/sample-script.js
    

2.Mint你的第一个NFT
==============

2.1 编写能够Mint的智能合约
-----------------

`OpenZeppelin`已经为我们实现了NFT标准`ERC721`中的基本内容，我们在此基础上修改即可。包内源码可以从Git上拉下来看：[🔗 OpenZeppelin源码](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol)。同时，`ERC721`的接口文档见：[🔗 ERC721](https://eips.ethereum.org/EIPS/eip-721) 另外需要解释的是`Solidity`已经提供了一些自有变量，如下面能看到的`msg.sender`能够返回当前处理的call的public address。通过这些变量可以更轻松的进行一些操作，具体见：[🔗 Solidity自有变量](https://docs.soliditylang.org/en/develop/units-and-global-variables.html#block-and-transaction-properties) 修改后的智能合约如下：

    pragma solidity ^0.8.1;
    
    // We first import some OpenZeppelin Contracts.
    // 首先我们import了OpenZeppelin包内的一些契约
    import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
    import "@openzeppelin/contracts/utils/Counters.sol";
    import "hardhat/console.sol";
    
    // We inherit the contract we imported. This means we'll have access
    // to the inherited contract's methods.
    // 我们继承了OpenZeppelin中导入的契约
    contract MyEpicNFT is ERC721URIStorage {
      // Magic given to us by OpenZeppelin to help us keep track of tokenIds.
      // _tokenIds是NFT的唯一标识符，例如#001、#002
      using Counters for Counters.Counter;
      Counters.Counter private _tokenIds;
    
      // We need to pass the name of our NFTs token and its symbol.
      constructor() ERC721 ("SquareNFT", "SQUARE") {
        console.log("This is my NFT contract. Woah!");
      }
    
      // A function our user will hit to get their NFT.
      // 通过Mint获取NFT的函数
      function makeAnEpicNFT() public {
         // Get the current tokenId, this starts at 0.
         // 拿去当前NFT的ID
        uint256 newItemId = _tokenIds.current();
    
         // Actually mint the NFT to the sender using msg.sender.
         // 将当前ID的NFT发送给该调用地址
        _safeMint(msg.sender, newItemId);
    
        // Set the NFTs data.
        // 设置NFTs唯一标识符以及与该唯一标识符相关的数据
        // 通过Json申明该NFT的metadata
        _setTokenURI(newItemId, "https://jsonkeeper.com/b/ONMH");
        console.log("An NFT w/ ID %s has been minted to %s", newItemId, msg.sender);
    
        // Increment the counter for when the next NFT is minted.
        // NFT ID ++
        _tokenIds.increment();
      }
    }
    

其中`tokenURI`用来link一个用Json描述的metadata，类似于：

    {
        "name": "Spongebob Cowboy Pants",
        "description": "A silent hero. A watchful protector.",
        "image": "https://i.imgur.com/v7U019j.png"
    }
    

而像OpenSea之类符合`ERC721`标准的网站就可以读取metadata的内容并展示在该NFT的对应位置。 可以通过[🔗 JSON Keeper](https://jsonkeeper.com/)保存你的metadata JSON。

2.2 编写JS调用智能合约
--------------

    touch ./scripts/run.js
    

使用该js调用智能合约中的`makeAnEpicNFT()`方法：

    const main = async () => {
        const nftContractFactory = await hre.ethers.getContractFactory('MyEpicNFT');
        const nftContract = await nftContractFactory.deploy();
        await nftContract.deployed();
        console.log("Contract deployed to:", nftContract.address);
      
        // Call the function.
        let txn = await nftContract.makeAnEpicNFT()
        // Wait for it to be mined.
        await txn.wait()
      
        // Mint another NFT for fun.
        txn = await nftContract.makeAnEpicNFT()
        // Wait for it to be mined.
        await txn.wait()
      
      };
      
      const runMain = async () => {
        try {
          await main();
          process.exit(0);
        } catch (error) {
          console.log(error);
          process.exit(1);
        }
      };
      
      runMain();
    

运行：

    npx hardhat run scripts/run.js
    

得到结果：

    Compiled 12 Solidity files successfully
    This is my NFT contract. Woah!
    Contract deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
    An NFT w/ ID 0 has been minted to 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
    An NFT w/ ID 1 has been minted to 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
    

则说明运行成功，且#0和#1已被Mint。但是目前所有人Mint的都是相同metadata描述的NFT。

2.3 部署到Rinkeby并在Opensea上查看
--------------------------

### 2.3.1 获取Alchemy API

> Transaction: 当我们进行一次改变区块链的动作时，我们将其称为Transaction。

Alchemy本质上帮助我们传播我们的合约创建transaction，以便它可以尽快被矿工接收。一旦挖掘到transaction，它就会将其作为合法合约广播到区块链，以便人们更新他们的区块链副本。 打开[🔗 Alchemy官网](https://www.alchemy.com/)并注册账号，新建一个App，选择`Ethereum`链以及`Rinkeby`网络（测试网络），点击你新创建的App，并在右上角`view key`按钮下找到你的API（API是私密的）。

### 2.3.2 获取测试网络ETH

接下来需要获取`Rinkeby`测试网络中的测试ETH，在小狐狸切换网络界面中选择打开测试网络，并切换到`Rinkeby`，通过[🔗 faucets](https://faucets.chain.link/rinkeby)链接钱包并获取测试ETH。等待交易完成且链上确认后，就能够在钱包中看到测试ETH了（需要一段时间）。

### 2.3.3 创建deploy.js文件

建议区分`deploy.js`以及`run.js`，将`run.js`复制一份到`deploy.js`：

    const main = async () => {
      const nftContractFactory = await hre.ethers.getContractFactory('MyEpicNFT');
      const nftContract = await nftContractFactory.deploy();
      await nftContract.deployed();
      console.log("Contract deployed to:", nftContract.address);
    
      // Call the function.
      let txn = await nftContract.makeAnEpicNFT()
      // Wait for it to be mined.
      await txn.wait()
      console.log("Minted NFT #1")
    
      txn = await nftContract.makeAnEpicNFT()
      // Wait for it to be mined.
      await txn.wait()
      console.log("Minted NFT #2")
    };
    
    const runMain = async () => {
      try {
        await main();
        process.exit(0);
      } catch (error) {
        console.log(error);
        process.exit(1);
      }
    };
    
    runMain();
    

### 2.3.4 部署Rinkeby测试网络

需要修改项目根目录的`hardhat.config.js`：

    require('@nomiclabs/hardhat-waffle');
    require("dotenv").config({ path: ".env" });
    
    module.exports = {
      solidity: '0.8.1',
      networks: {
        rinkeby: {
          url: process.env.ALCHEMY_API_KEY_URL,
          accounts: [process.env.RINKEBY_PRIVATE_KEY],
        },
      },
    };
    

安装dotenv以便使用env变量:

在根目录新建一个`.env`，由于需要将私钥登记在此，所以需要将该后缀名在`.gitignore`中确认登记。 编写该env文件：

    ALCHEMY_API_KEY_URL=<YOUR API URL>
    RINKEBY_PRIVATE_KEY=<YOUR PRIVATE KEY>
    

其中`YOUR API URL`为`Alchemy`中`VIEW KEY`按钮下的`HTTP`，私钥请参考[🔗 小狐狸私钥获取指南](https://metamask.zendesk.com/hc/en-us/articles/360015289632-How-to-Export-an-Account-Private-Key)获取。

> 尽量使用空钱包进行操作！ 部署完成后在项目根目录运行：

    npx hardhat run scripts/deploy.js --network rinkeby
    

应该能够看到：

    Contract deployed to: 0xCaBb395dd8eCa90410B7e0fBBDA3Eb079F377C74
    Minted NFT #1
    Minted NFT #2
    

在[🔗 Rinkeby Etherscan](https://rinkeby.etherscan.io/)中输入你的合约部署地址查看记录是否正确。

### 2.3.5 在OS Test上查看NFT

[🔗 OS Test](https://testnets.opensea.io/) 将合约地址复制进搜索框，等结果出来后点击结果即可查看。 如果NFT暂时未显示，请稍等片刻即可，OS需要刷新metadata。

3.生成链上NFT
=========

在这一章我们需要建造随机的由三个字母组成的NFT。

3.1 SVG是什么
----------

NFT通常采用SVG格式，该格式通过代码构建，例如：

    <svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 350 350">
        <style>.base { fill: white; font-family: serif; font-size: 14px; }</style>
        <rect width="100%" height="100%" fill="black" />
        <text x="50%" y="50%" class="base" dominant-baseline="middle" text-anchor="middle">EpicLordHamburger</text>
    </svg>
    

3.2 创造我们的SVG
------------

[🔗 base64 encoder](https://www.utilities-online.info/base64) 我们将上述的svg利用这个网站转换为base64编码：

    PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCAzNTAgMzUwIj4KICAgIDxzdHlsZT4uYmFzZSB7IGZpbGw6IHdoaXRlOyBmb250LWZhbWlseTogc2VyaWY7IGZvbnQtc2l6ZTogMTRweDsgfTwvc3R5bGU+CiAgICA8cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJibGFjayIgLz4KICAgIDx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBjbGFzcz0iYmFzZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+RXBpY0xvcmRIYW1idXJnZXI8L3RleHQ+Cjwvc3ZnPg==
    

将其拼接并在浏览器中查看图片：

    data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCAzNTAgMzUwIj4KICAgIDxzdHlsZT4uYmFzZSB7IGZpbGw6IHdoaXRlOyBmb250LWZhbWlseTogc2VyaWY7IGZvbnQtc2l6ZTogMTRweDsgfTwvc3R5bGU+CiAgICA8cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJibGFjayIgLz4KICAgIDx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBjbGFzcz0iYmFzZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+RXBpY0xvcmRIYW1idXJnZXI8L3RleHQ+Cjwvc3ZnPg==
    

只要有这个base64，就可以还原出该svg。

3.3 摆脱JSON
----------

将matadata指向上述base64字符串：

    {
        "name": "EpicLordHamburger",
        "description": "An NFT from the highly acclaimed square collection",
        "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCAzNTAgMzUwIj4KICAgIDxzdHlsZT4uYmFzZSB7IGZpbGw6IHdoaXRlOyBmb250LWZhbWlseTogc2VyaWY7IGZvbnQtc2l6ZTogMTRweDsgfTwvc3R5bGU+CiAgICA8cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJibGFjayIgLz4KICAgIDx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBjbGFzcz0iYmFzZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+RXBpY0xvcmRIYW1idXJnZXI8L3RleHQ+Cjwvc3ZnPg=="
    }
    

并再用[🔗 base64 encoder](https://www.utilities-online.info/base64)对整个Json串进行一次base64编码：

    ewogICAgIm5hbWUiOiAiRXBpY0xvcmRIYW1idXJnZXIiLAogICAgImRlc2NyaXB0aW9uIjogIkFuIE5GVCBmcm9tIHRoZSBoaWdobHkgYWNjbGFpbWVkIHNxdWFyZSBjb2xsZWN0aW9uIiwKICAgICJpbWFnZSI6ICJkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUI0Yld4dWN6MGlhSFIwY0RvdkwzZDNkeTUzTXk1dmNtY3ZNakF3TUM5emRtY2lJSEJ5WlhObGNuWmxRWE53WldOMFVtRjBhVzg5SW5oTmFXNVpUV2x1SUcxbFpYUWlJSFpwWlhkQ2IzZzlJakFnTUNBek5UQWdNelV3SWo0S0lDQWdJRHh6ZEhsc1pUNHVZbUZ6WlNCN0lHWnBiR3c2SUhkb2FYUmxPeUJtYjI1MExXWmhiV2xzZVRvZ2MyVnlhV1k3SUdadmJuUXRjMmw2WlRvZ01UUndlRHNnZlR3dmMzUjViR1UrQ2lBZ0lDQThjbVZqZENCM2FXUjBhRDBpTVRBd0pTSWdhR1ZwWjJoMFBTSXhNREFsSWlCbWFXeHNQU0ppYkdGamF5SWdMejRLSUNBZ0lEeDBaWGgwSUhnOUlqVXdKU0lnZVQwaU5UQWxJaUJqYkdGemN6MGlZbUZ6WlNJZ1pHOXRhVzVoYm5RdFltRnpaV3hwYm1VOUltMXBaR1JzWlNJZ2RHVjRkQzFoYm1Ob2IzSTlJbTFwWkdSc1pTSStSWEJwWTB4dmNtUklZVzFpZFhKblpYSThMM1JsZUhRK0Nqd3ZjM1puUGc9PSIKfQ==
    

用浏览器查看该Json：

    data:application/json;base64,ewogICAgIm5hbWUiOiAiRXBpY0xvcmRIYW1idXJnZXIiLAogICAgImRlc2NyaXB0aW9uIjogIkFuIE5GVCBmcm9tIHRoZSBoaWdobHkgYWNjbGFpbWVkIHNxdWFyZSBjb2xsZWN0aW9uIiwKICAgICJpbWFnZSI6ICJkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUI0Yld4dWN6MGlhSFIwY0RvdkwzZDNkeTUzTXk1dmNtY3ZNakF3TUM5emRtY2lJSEJ5WlhObGNuWmxRWE53WldOMFVtRjBhVzg5SW5oTmFXNVpUV2x1SUcxbFpYUWlJSFpwWlhkQ2IzZzlJakFnTUNBek5UQWdNelV3SWo0S0lDQWdJRHh6ZEhsc1pUNHVZbUZ6WlNCN0lHWnBiR3c2SUhkb2FYUmxPeUJtYjI1MExXWmhiV2xzZVRvZ2MyVnlhV1k3SUdadmJuUXRjMmw2WlRvZ01UUndlRHNnZlR3dmMzUjViR1UrQ2lBZ0lDQThjbVZqZENCM2FXUjBhRDBpTVRBd0pTSWdhR1ZwWjJoMFBTSXhNREFsSWlCbWFXeHNQU0ppYkdGamF5SWdMejRLSUNBZ0lEeDBaWGgwSUhnOUlqVXdKU0lnZVQwaU5UQWxJaUJqYkdGemN6MGlZbUZ6WlNJZ1pHOXRhVzVoYm5RdFltRnpaV3hwYm1VOUltMXBaR1JzWlNJZ2RHVjRkQzFoYm1Ob2IzSTlJbTFwWkdSc1pTSStSWEJwWTB4dmNtUklZVzFpZFhKblpYSThMM1JsZUhRK0Nqd3ZjM1puUGc9PSIKfQ==
    

3.4 修改合约并部署
-----------

将上述内容以下面的格式复制进合约中并修改Token URI：

    _setTokenURI(newItemId, "data:application/json;base64,INSERT_BASE_64_ENCODED_JSON_HERE")
    

修改完成后再部署合约并Mint：

    npx hardhat run scripts/deploy.js --network rinkeby
    

与第二章相同，去[🔗 OS Test](https://testnets.opensea.io/)上查看我们的NFT。 如果OS Test刷新太慢可以通过以下网址拼接检查：

    https://rinkeby.rarible.com/token/INSERT_DEPLOY_CONTRACT_ADDRESS_HERE:INSERT_TOKEN_ID_HERE
    

3.5 在图片上生成随机单词
--------------

在Svg生成随机单词的思路是：

1.  申明随机单词数组；
    
2.  从申明的数组中随机选出单词；
    
3.  将随机选出的单词拼接进Svg表达式中；
    
4.  将Svg转为Base64并拼接进Json；
    
5.  将Json转为Base64；
    
6.  将得到的Base64拼接加入前缀得到最终URI
    
7.  最后的到的URI作为finalTokenUri通过\_setTokenURI与NFT编号绑定
    

### 3.5.1 String to Base64

由上述分析可知我们需要一个String转换Base64的方法：

    mkdir ./contracts/libraries
    touch Base64.sol
    

并写入转换逻辑：

    /**
     *Submitted for verification at Etherscan.io on 2021-09-05
     */
    
    // SPDX-License-Identifier: MIT
    
    pragma solidity ^0.8.1;
    
    /// [MIT License]
    /// @title Base64
    /// @notice Provides a function for encoding some bytes in base64
    /// @author Brecht Devos <brecht@loopring.org>
    library Base64 {
        bytes internal constant TABLE =
            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    
        /// @notice Encodes some bytes to the base64 representation
        function encode(bytes memory data) internal pure returns (string memory) {
            uint256 len = data.length;
            if (len == 0) return "";
    
            // multiply by 4/3 rounded up
            uint256 encodedLen = 4 * ((len + 2) / 3);
    
            // Add some extra buffer at the end
            bytes memory result = new bytes(encodedLen + 32);
    
            bytes memory table = TABLE;
    
            assembly {
                let tablePtr := add(table, 1)
                let resultPtr := add(result, 32)
    
                for {
                    let i := 0
                } lt(i, len) {
    
                } {
                    i := add(i, 3)
                    let input := and(mload(add(data, i)), 0xffffff)
    
                    let out := mload(add(tablePtr, and(shr(18, input), 0x3F)))
                    out := shl(8, out)
                    out := add(
                        out,
                        and(mload(add(tablePtr, and(shr(12, input), 0x3F))), 0xFF)
                    )
                    out := shl(8, out)
                    out := add(
                        out,
                        and(mload(add(tablePtr, and(shr(6, input), 0x3F))), 0xFF)
                    )
                    out := shl(8, out)
                    out := add(
                        out,
                        and(mload(add(tablePtr, and(input, 0x3F))), 0xFF)
                    )
                    out := shl(224, out)
    
                    mstore(resultPtr, out)
    
                    resultPtr := add(resultPtr, 4)
                }
    
                switch mod(len, 3)
                case 1 {
                    mstore(sub(resultPtr, 2), shl(240, 0x3d3d))
                }
                case 2 {
                    mstore(sub(resultPtr, 1), shl(248, 0x3d))
                }
    
                mstore(result, encodedLen)
            }
    
            return string(result);
        }
    }
    

### 3.5.2 编写合约

有了转换Base64的方法类，就很容易根据上述逻辑编写合约了：

    pragma solidity ^0.8.1;
    
    // We need some util functions for strings.
    import "@openzeppelin/contracts/utils/Strings.sol";
    import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
    import "@openzeppelin/contracts/utils/Counters.sol";
    import "hardhat/console.sol";
    
    // import Base64.sol
    import { Base64 } from "./libraries/Base64.sol";
    
    contract MyEpicNFT is ERC721URIStorage {
      using Counters for Counters.Counter;
      Counters.Counter private _tokenIds;
    
      // This is our SVG code. All we need to change is the word that's displayed. Everything else stays the same.
      // So, we make a baseSvg variable here that all our NFTs can use.
      // 除了<text>其他属性都一致，需要通过random方法获取随机词组后拼接完成Svg的描述
      string baseSvg = "<svg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMinYMin meet' viewBox='0 0 350 350'><style>.base { fill: white; font-family: serif; font-size: 24px; }</style><rect width='100%' height='100%' fill='black' /><text x='50%' y='50%' class='base' dominant-baseline='middle' text-anchor='middle'>";
    
      // I create three arrays, each with their own theme of random words.
      // Pick some random funny words, names of anime characters, foods you like, whatever! 
      // 申明随机单词数组
      string[] firstWords = ["Fantastic", "Epic", "Terrible", "Crazy", "Wild", "Terrifying"];
      string[] secondWords = ["Cupcake", "Pizza", "Milk", "Curry", "Chicken", "Salad"];
      string[] thirdWords = ["InMyMouth", "OnBed", "UnderFloor", "InBox", "InPlayGround", "OnTheMoon"];
    
      constructor() ERC721 ("SquareNFT", "SQUARE") {
        console.log("This is my NFT contract. Woah!");
      }
    
      // I create a function to randomly pick a word from each array.
      // 从数组中随机获取一个单词
      function pickRandomFirstWord(uint256 tokenId) public view returns (string memory) {
        // I seed the random generator. 
        // 随机种子为"FIRST_WORD" + tokenId
        uint256 rand = random(string(abi.encodePacked("FIRST_WORD", Strings.toString(tokenId))));
        // Squash the # between 0 and the length of the array to avoid going out of bounds.
        rand = rand % firstWords.length;
        return firstWords[rand];
      }
    
      function pickRandomSecondWord(uint256 tokenId) public view returns (string memory) {
        uint256 rand = random(string(abi.encodePacked("SECOND_WORD", Strings.toString(tokenId))));
        rand = rand % secondWords.length;
        return secondWords[rand];
      }
    
      function pickRandomThirdWord(uint256 tokenId) public view returns (string memory) {
        uint256 rand = random(string(abi.encodePacked("THIRD_WORD", Strings.toString(tokenId))));
        rand = rand % thirdWords.length;
        return thirdWords[rand];
      }
    
      function random(string memory input) internal pure returns (uint256) {
          return uint256(keccak256(abi.encodePacked(input)));
      }
    
        function makeAnEpicNFT() public {
        uint256 newItemId = _tokenIds.current();
    
        string memory first = pickRandomFirstWord(newItemId);
        string memory second = pickRandomSecondWord(newItemId);
        string memory third = pickRandomThirdWord(newItemId);
        string memory combinedWord = string(abi.encodePacked(first, second, third));
    
        string memory finalSvg = string(abi.encodePacked(baseSvg, combinedWord, "</text></svg>"));
    
        // Get all the JSON metadata in place and base64 encode it.
        // Json转换为Base64
        string memory json = Base64.encode(
            bytes(
                string(
                    abi.encodePacked(
                        '{"name": "',
                        // We set the title of our NFT as the generated word.
                        combinedWord,
                        '", "description": "A highly acclaimed collection of squares.", "image": "data:image/svg+xml;base64,',
                        // We add data:image/svg+xml;base64 and then append our base64 encode our svg.
                        // Svg转换为Base64
                        Base64.encode(bytes(finalSvg)),
                        '"}'
                    )
                )
            )
        );
    
        // Just like before, we prepend data:application/json;base64, to our data.
        string memory finalTokenUri = string(
            abi.encodePacked("data:application/json;base64,", json)
        );
    
        console.log("\n--------------------");
        console.log(finalTokenUri);
        console.log("--------------------\n");
    
        _safeMint(msg.sender, newItemId);
        
        // Update your URI!!!
        _setTokenURI(newItemId, finalTokenUri);
      
        _tokenIds.increment();
        console.log("An NFT w/ ID %s has been minted to %s", newItemId, msg.sender);
      }
    }
    

### 3.5.3 测试合约

    npx hardhat run scripts/run.js
    

查看从控制台打印的finalTokenUri，将得到的finalTokenUri粘贴进[🔗 NFT preview](https://nftpreview.0xdev.codes/)进行Debug，这比起经过OS Test查看更加方便。 查看无误后可以部署在Rinkeby并通过OS Test查看：

    npx hardhat run scripts/deploy.js --network rinkeby
    

### 3.6 使用Replit初始化Web App

[🔗 Replit](https://replit.com/~)是一款基于网页的IDE，允许你在web端构建web应用。 注册完成后点击[🔗 nft-starter-project](https://replit.com/@adilanchian/nft-starter-project?v=1)后`Fork repo`进自己的库，并单击`Run`尝试运行。

### 3.7 设置MetaMask并链接至Web App

需要下载MetaMask并登陆钱包。 为了让我们的网站与区块链通信，我们需要以某种方式将我们的钱包与它连接起来。一旦我们将钱包连接到我们的网站，我们的网站将有权代表我们调用智能合约。 如果我们登陆一个MetaMask，它就会自动在我们的window下注入一个名为`ethereum`的Object，并能够通过它完成一系列操作。 在Replit的`src` -> `App.jsx`下进行代码编写来测试我们是否成功拿到了`ethereum`:

    import React, { useEffect } from "react";
    import './styles/App.css';
    import twitterLogo from './assets/twitter-logo.svg';
    
    // Constants
    const TWITTER_HANDLE = '_buildspace';
    const TWITTER_LINK = `https://twitter.com/${TWITTER_HANDLE}`;
    const OPENSEA_LINK = '';
    const TOTAL_MINT_COUNT = 50;
    
    const App = () => {
    
      const checkIfWalletIsConnected = () => {
        /*
        * First make sure we have access to window.ethereum
        */
        const { ethereum } = window;
    
        if (!ethereum) {
          console.log("Make sure you have metamask!");
          return;
        } else {
          console.log("We have the ethereum object", ethereum);
        }
      }
    
      // Render Methods
      const renderNotConnectedContainer = () => (
        <button className="cta-button connect-wallet-button">
          Connect to Wallet
        </button>
      );
    
      /*
      * This runs our function when the page loads.
      */
      useEffect(() => {
        checkIfWalletIsConnected();
      }, [])
    
      return (
        <div className="App">
          <div className="container">
            <div className="header-container">
              <p className="header gradient-text">My NFT Collection</p>
              <p className="sub-text">
                Each unique. Each beautiful. Discover your NFT today.
              </p>
              {/* Add your render method here */}
              {renderNotConnectedContainer()}
            </div>
            <div className="footer-container">
              <img alt="Twitter Logo" className="twitter-logo" src={twitterLogo} />
              <a
                className="footer-text"
                href={TWITTER_LINK}
                target="_blank"
                rel="noreferrer"
              >{`built on @${TWITTER_HANDLE}`}</a>
            </div>
          </div>
        </div>
      );
    };
    
    export default App;
    

在Replit IDE的右上角选择分享按钮样式的`open in a new tab`，打开新的独立页面并`fn + F12`呼出控制台查看是否打印出的语句：

    We have the ethereum object
    

若没打印请确保目前浏览器的Meta Mask处于可用状态。 接下来，我们需要实际检查我们是否被授权实际访问用户的钱包。一旦我们有权限，我们就能调用智能合约。 修改`App.jsx`：

    import React, { useEffect, useState } from "react";
    import './styles/App.css';
    import twitterLogo from './assets/twitter-logo.svg';
    
    // Constants
    const TWITTER_HANDLE = '_buildspace';
    const TWITTER_LINK = `https://twitter.com/${TWITTER_HANDLE}`;
    const OPENSEA_LINK = '';
    const TOTAL_MINT_COUNT = 50;
    
    const App = () => {
    
      /*
      * Just a state variable we use to store our user's public wallet. Don't forget to import useState.
      */
      const [currentAccount, setCurrentAccount] = useState("");
      
      /*
      * Gotta make sure this is async.
      */
      const checkIfWalletIsConnected = async () => {
        const { ethereum } = window;
    
        if (!ethereum) {
            console.log("Make sure you have metamask!");
            return;
        } else {
            console.log("We have the ethereum object", ethereum);
        }
    
        /*
        * Check if we're authorized to access the user's wallet
        */
        const accounts = await ethereum.request({ method: 'eth_accounts' });
    
        /*
        * User can have multiple authorized accounts, we grab the first one if its there!
        */
        if (accounts.length !== 0) {
          const account = accounts[0];
          console.log("Found an authorized account:", account);
          setCurrentAccount(account);
        } else {
          console.log("No authorized account found");
        }
      }
    
      // Render Methods
      const renderNotConnectedContainer = () => (
        <button className="cta-button connect-wallet-button">
          Connect to Wallet
        </button>
      );
    
      useEffect(() => {
        checkIfWalletIsConnected();
      }, [])
    
      return (
         <div className="App">
          <div className="container">
            <div className="header-container">
              <p className="header gradient-text">My NFT Collection</p>
              <p className="sub-text">
                Each unique. Each beautiful. Discover your NFT today.
              </p>
              {renderNotConnectedContainer()}
            </div>
            <div className="footer-container">
              <img alt="Twitter Logo" className="twitter-logo" src={twitterLogo} />
              <a
                className="footer-text"
                href={TWITTER_LINK}
                target="_blank"
                rel="noreferrer"
              >{`built on @${TWITTER_HANDLE}`}</a>
            </div>
          </div>
        </div>
      );
    };
    
    export default App;
    

运行上述代码时，控制台里输出的是：

    No authorized account found
    

因为我们还没有向Meta Mask询问并获取权限，需要修改`Connect to Wallet`按钮的逻辑。 获取权限的方法为：

    const accounts = await ethereum.request({ method: "eth_requestAccounts" });
    

`App.jsx`修改为：

    import React, { useEffect, useState } from "react";
    import './styles/App.css';
    import twitterLogo from './assets/twitter-logo.svg';
    
    const TWITTER_HANDLE = '_buildspace';
    const TWITTER_LINK = `https://twitter.com/${TWITTER_HANDLE}`;
    const OPENSEA_LINK = '';
    const TOTAL_MINT_COUNT = 50;
    
    const App = () => {
      const [currentAccount, setCurrentAccount] = useState("");
      
      const checkIfWalletIsConnected = async () => {
        const { ethereum } = window;
    
        if (!ethereum) {
          console.log("Make sure you have metamask!");
          return;
        } else {
          console.log("We have the ethereum object", ethereum);
        }
    
        const accounts = await ethereum.request({ method: 'eth_accounts' });
    
        if (accounts.length !== 0) {
          const account = accounts[0];
          console.log("Found an authorized account:", account);
          setCurrentAccount(account);
        } else {
          console.log("No authorized account found");
        }
      }
    
      /*
      * Implement your connectWallet method here
      */
      const connectWallet = async () => {
        try {
          const { ethereum } = window;
    
          if (!ethereum) {
            alert("Get MetaMask!");
            return;
          }
    
          /*
          * Fancy method to request access to account.
          */
          const accounts = await ethereum.request({ method: "eth_requestAccounts" });
    
          /*
          * Boom! This should print out public address once we authorize Metamask.
          */
          console.log("Connected", accounts[0]);
          setCurrentAccount(accounts[0]); 
        } catch (error) {
          console.log(error);
        }
      }
    
      // Render Methods
      const renderNotConnectedContainer = () => (
        <button onClick={connectWallet} className="cta-button connect-wallet-button">
          Connect to Wallet
        </button>
      );
    
      useEffect(() => {
        checkIfWalletIsConnected();
      }, [])
    
      /*
      * Added a conditional render! We don't want to show Connect to Wallet if we're already connected :).
      */
      return (
        <div className="App">
          <div className="container">
            <div className="header-container">
              <p className="header gradient-text">My NFT Collection</p>
              <p className="sub-text">
                Each unique. Each beautiful. Discover your NFT today.
              </p>
              {currentAccount === "" ? (
                renderNotConnectedContainer()
              ) : (
                <button onClick={null} className="cta-button connect-wallet-button">
                  Mint NFT
                </button>
              )}
            </div>
            <div className="footer-container">
              <img alt="Twitter Logo" className="twitter-logo" src={twitterLogo} />
              <a
                className="footer-text"
                href={TWITTER_LINK}
                target="_blank"
                rel="noreferrer"
              >{`built on @${TWITTER_HANDLE}`}</a>
            </div>
          </div>
        </div>
      );
    };
    
    export default App;
    

点击按钮后获取address成功：

    Found an authorized account: 0x7e44639c5daa727f5035cf59cb....
    

3.8 Mint NFT按钮
--------------

我们的合约中有`makeAnEpicNFT()`方法，这个方法允许我们Mint NFT，现在要做的就是从Web App去call这个方法，代码逻辑（目前还有报错）如下所示：

    import { ethers } from "ethers";
    
    const askContractToMintNft = async () => {
      const CONTRACT_ADDRESS = "INSERT_YOUR_DEPLOYED_RINKEBY_CONTRACT_ADDRESS";
    
      try {
        const { ethereum } = window;
    
        if (ethereum) {
          const provider = new ethers.providers.Web3Provider(ethereum);
          const signer = provider.getSigner();
          const connectedContract = new ethers.Contract(CONTRACT_ADDRESS, myEpicNft.abi, signer);
    
          console.log("Going to pop wallet now to pay gas...")
          let nftTxn = await connectedContract.makeAnEpicNFT();
    
          console.log("Mining...please wait.")
          await nftTxn.wait();
          
          console.log(`Mined, see transaction: https://rinkeby.etherscan.io/tx/${nftTxn.hash}`);
    
        } else {
          console.log("Ethereum object doesn't exist!");
        }
      } catch (error) {
        console.log(error)
      }
    }
    

其中引入的[🔗 ethers](https://docs.ethers.io/v5/api/)是一个支持前端与智能合约链接的库，其中`provider`是我们与以太坊节点进行实际通信使用的；`connectedContract`与实际合约进行连接，这需要我们提供合约地址、abi文件（见后文的3.9章）以及签名。

    import { ethers } from "ethers";
    
    const provider = new ethers.providers.Web3Provider(ethereum);
    const signer = provider.getSigner();
    const connectedContract = new ethers.Contract(CONTRACT_ADDRESS, myEpicNft.abi, signer);
    

得到`connectedContract`连接后，可以去call合约的`makeAnEpicNFT()`方法即可：

    console.log("Going to pop wallet now to pay gas...")
    let nftTxn = await connectedContract.makeAnEpicNFT();
    
    console.log("Mining...please wait.")
    await nftTxn.wait();
    
    console.log(`Mined, see transaction: https://rinkeby.etherscan.io/tx/${nftTxn.hash}`);
    

最后是该方法的调用，每当有人按下Mint Now按钮的时候，去调用上述逻辑`askContractToMintNft()`：

    return (
      {currentAccount === "" 
        ? renderNotConnectedContainer()
        : (
          /** Add askContractToMintNft Action for the onClick event **/
          <button onClick={askContractToMintNft} className="cta-button connect-wallet-button">
            Mint NFT
          </button>
        )
      }
    );
    

3.9 ABI File
------------

每次build智能合约时，在`./artifacts/contracts/Greeter.sol`文件夹下都有两个JSON，选取不带`dbg`的那个复制到web app的`src/util`目录下。并import到`App.jsx`：

    import myEpicNft from './utils/MyEpicNFT.json';
    

每次更新合约都需要更新你的ABI File与CONTRACT\_ADDRESS。 现在可以重新部署你的合约并修改前端的ABI File与CONTRACT\_ADDRESS。以我的合约为例，前端代码如下：

    import React, { useEffect, useState } from "react";
    import './styles/App.css';
    import twitterLogo from './assets/twitter-logo.svg';
    import { ethers } from "ethers";
    import myEpicNft from './utils/MyEpicNFT.json';
    
    const TWITTER_HANDLE = '_buildspace';
    const TWITTER_LINK = `https://twitter.com/${TWITTER_HANDLE}`;
    const OPENSEA_LINK = '';
    const TOTAL_MINT_COUNT = 50;
    
    const App = () => {
      const [currentAccount, setCurrentAccount] = useState("");
      
      const checkIfWalletIsConnected = async () => {
        const { ethereum } = window;
    
        if (!ethereum) {
          console.log("Make sure you have metamask!");
          return;
        } else {
          console.log("We have the ethereum object", ethereum);
        }
    
        const accounts = await ethereum.request({ method: 'eth_accounts' });
    
        if (accounts.length !== 0) {
          const account = accounts[0];
          console.log("Found an authorized account:", account);
          setCurrentAccount(account);
        } else {
          console.log("No authorized account found");
        }
      }
    
      /*
      * Implement your connectWallet method here
      */
      const connectWallet = async () => {
        try {
          const { ethereum } = window;
    
          if (!ethereum) {
            alert("Get MetaMask!");
            return;
          }
    
          /*
          * Fancy method to request access to account.
          */
          const accounts = await ethereum.request({ method: "eth_requestAccounts" });
    
          /*
          * Boom! This should print out public address once we authorize Metamask.
          */
          console.log("Connected", accounts[0]);
          setCurrentAccount(accounts[0]); 
        } catch (error) {
          console.log(error);
        }
      }
    
      const askContractToMintNft = async () => {
      const CONTRACT_ADDRESS = "0xAdF931Efff7F2579E13b97b83969582DCD5E0c69";
    
      try {
        const { ethereum } = window;
    
        if (ethereum) {
          const provider = new ethers.providers.Web3Provider(ethereum);
          const signer = provider.getSigner();
          const connectedContract = new ethers.Contract(CONTRACT_ADDRESS, myEpicNft.abi, signer);
    
          console.log("Going to pop wallet now to pay gas...")
          let nftTxn = await connectedContract.makeAnEpicNFT();
    
          console.log("Mining...please wait.")
          await nftTxn.wait();
          
          console.log(`Mined, see transaction: https://rinkeby.etherscan.io/tx/${nftTxn.hash}`);
    
        } else {
          console.log("Ethereum object doesn't exist!");
        }
      } catch (error) {
        console.log(error)
      }
    }
      
    
      // Render Methods
      const renderNotConnectedContainer = () => (
        <button onClick={connectWallet} className="cta-button connect-wallet-button">
          Connect to Wallet
        </button>
      );
    
      useEffect(() => {
        checkIfWalletIsConnected();
      }, [])
    
      /*
      * Added a conditional render! We don't want to show Connect to Wallet if we're already connected :).
      */
      return (
        <div className="App">
          <div className="container">
            <div className="header-container">
              <p className="header gradient-text">My NFT Collection</p>
              <p className="sub-text">
                Each unique. Each beautiful. Discover your NFT today.
              </p>
              {currentAccount === "" ? (
                renderNotConnectedContainer()
              ) : (
                <button onClick={askContractToMintNft} className="cta-button connect-wallet-button">
                  Mint NFT
                </button>
              )}
            </div>
            <div className="footer-container">
              <img alt="Twitter Logo" className="twitter-logo" src={twitterLogo} />
              <a
                className="footer-text"
                href={TWITTER_LINK}
                target="_blank"
                rel="noreferrer"
              >{`built on @${TWITTER_HANDLE}`}</a>
            </div>
          </div>
        </div>
      );
    };
    
    export default App;
    

之后链接钱包至`Rinkeby`链后点击Mint Now后交gas即可Mint，可以在控制台看到如[🔗 Transaction Details](https://rinkeby.etherscan.io/tx/0xaeafbab5cae78b0a25a07af9eb81af7e2cf4a8b5690aae3fe176218c0e7402b0)一样的交易记录。

3.10 拿取Token ID
---------------

利用`Events`将Token ID传到前端并拼接成Mint的NFT的OS链接。 在智能合约的随机字符组合完成后添加：

    event NewEpicNFTMinted(address sender, uint256 tokenId);
    

并在合约的最后一行添加：

    emit NewEpicNFTMinted(msg.sender, newItemId);
    

事实上`Event`类似于合约抛出的消息，更多信息可以参考[🔗 Event](https://docs.soliditylang.org/en/v0.4.21/contracts.html#events)。 更新智能合约，记得需要同时更新ADDRESS及ABI File！ 在前端添加逻辑以显示OS链接：

    connectedContract.on("NewEpicNFTMinted", (from, tokenId) => {
        console.log(from, tokenId.toNumber())
        alert(`Hey there! We've minted your NFT. It may be blank right now. It can take a max of 10 min to show up on OpenSea. Here's the link: <https://testnets.opensea.io/assets/${CONTRACT_ADDRESS}/${tokenId.toNumber()}>`)
    });
    

所有的代码可以查看[🔗 Github](https://gist.github.com/farzaa/5015532446dfdb267711592107a285a9)

4.IPFS
======

如果上传到Github，不要上传你的hardhat配置文件与你的私钥到你的repo。 如果NFT较大，存储在以太坊成本太高，可以尝试了解[🔗 IPFS](https://en.wikipedia.org/wiki/InterPlanetary_File_System)。使用它非常简单，你所需要做的就是将你的NFT上传到IPFS，然后使用它在合同中给你的唯一内容ID哈希，而不是Imgur URL或Svg数据。例如，将NFT上传至[🔗 Pinata](https://www.pinata.cloud/?utm_source=buildspace)，获取`CID`并拼接IPFS地址：

    https://cloudflare-ipfs.com/ipfs/INSERT_YOUR_CID_HERE
    

在智能合约中修改Token URI为：

    _setTokenURI(newItemId, "ipfs://INSERT_YOUR_CID_HERE")
    

有一点，随机生成的Svg无法直接通过合约传递给IPFS。 请记住，NFT最终只是一个链接到一些元数据的JSON文件。您可以将这个JSON文件放到IPFS上。你也可以把NFT数据本身(比如图像、视频等)放到IPFS上。不要把它复杂化。

5.验证智能合约
========

现在合约在etherscan的`Contract`显示为为字节码，[🔗 可以查询自己的合约链接](https://rinkeby.etherscan.io/address/0x218C8cE07001df5c4045BADd78696603925A7d03#code) 安装`@nomiclabs/hardhat-etherscan`：

    npm i -D @nomiclabs/hardhat-etherscan
    

在[🔗 etherscan](https://etherscan.io/register)注册一个账户并拿取API，填入`./hardhat.config.js`(不要将密钥告诉别人)：

    require('@nomiclabs/hardhat-waffle');
    require("@nomiclabs/hardhat-etherscan");
    require("dotenv").config({ path: ".env" });
    
    module.exports = {
      solidity: '0.8.1',
      networks: {
        rinkeby: {
          url: process.env.ALCHEMY_API_KEY_URL,
          accounts: [process.env.RINKEBY_PRIVATE_KEY],
        },
      },
      etherscan: {
        // Your API key for Etherscan
        // Obtain one at https://etherscan.io/
        apiKey: "xxxx...xxxx",
      }
    };
    

填好之后运行（修改为自己的合约地址）：

    npx hardhat verify YOUR_CONTRACT_ADDRESS --network rinkeby
    
    // such us
    npx hardhat verify 0x218C8cE07001df5c4045BADd78696603925A7d03 --network rinkeby
    

成功后原先的字节码会变为sol代码。

6.更多
====

创造收费的NFT，参考：[🔗 这里](https://forum.openzeppelin.com/t/implementation-of-sellable-nft/5517) 增加版税，参考：[🔗 这里](https://eips.ethereum.org/EIPS/eip-2981)

---

*Originally published on [RuoYi](https://paragraph.com/@ruoyi/nft)*
