Cover photo

NFT开发入门教程

1.环境准备

本文环境准备以 Mac OS 为例,在 version 12.0.1 下进行开发 本文教程基于🔗 buildspace的课程:Mint your own NFT collection and ship a Web3 app to show them off

1.1 本地开发环境准备

HardHat是一个允许我们快速创建智能合约并在本地部署的工具,具体介绍见官网链接:🔗 HardHat 前置安装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源码。同时,ERC721的接口文档见:🔗 ERC721 另外需要解释的是Solidity已经提供了一些自有变量,如下面能看到的msg.sender能够返回当前处理的call的public address。通过这些变量可以更轻松的进行一些操作,具体见:🔗 Solidity自有变量 修改后的智能合约如下:

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保存你的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官网并注册账号,新建一个App,选择Ethereum链以及Rinkeby网络(测试网络),点击你新创建的App,并在右上角view key按钮下找到你的API(API是私密的)。

2.3.2 获取测试网络ETH

接下来需要获取Rinkeby测试网络中的测试ETH,在小狐狸切换网络界面中选择打开测试网络,并切换到Rinkeby,通过🔗 faucets链接钱包并获取测试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 URLAlchemyVIEW KEY按钮下的HTTP,私钥请参考🔗 小狐狸私钥获取指南获取。

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

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

应该能够看到:

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

🔗 Rinkeby Etherscan中输入你的合约部署地址查看记录是否正确。

2.3.5 在OS Test上查看NFT

🔗 OS Test 将合约地址复制进搜索框,等结果出来后点击结果即可查看。 如果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 我们将上述的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对整个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上查看我们的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进行Debug,这比起经过OS Test查看更加方便。 查看无误后可以部署在Rinkeby并通过OS Test查看:

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

3.6 使用Replit初始化Web App

🔗 Replit是一款基于网页的IDE,允许你在web端构建web应用。 注册完成后点击🔗 nft-starter-projectFork 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是一个支持前端与智能合约链接的库,其中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一样的交易记录。

3.10 拿取Token ID

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

event NewEpicNFTMinted(address sender, uint256 tokenId);

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

emit NewEpicNFTMinted(msg.sender, newItemId);

事实上Event类似于合约抛出的消息,更多信息可以参考🔗 Event。 更新智能合约,记得需要同时更新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

4.IPFS

如果上传到Github,不要上传你的hardhat配置文件与你的私钥到你的repo。 如果NFT较大,存储在以太坊成本太高,可以尝试了解🔗 IPFS。使用它非常简单,你所需要做的就是将你的NFT上传到IPFS,然后使用它在合同中给你的唯一内容ID哈希,而不是Imgur URL或Svg数据。例如,将NFT上传至🔗 Pinata,获取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显示为为字节码,🔗 可以查询自己的合约链接 安装@nomiclabs/hardhat-etherscan

npm i -D @nomiclabs/hardhat-etherscan

🔗 etherscan注册一个账户并拿取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,参考:🔗 这里 增加版税,参考:🔗 这里