SVG NFT 全面实践 ── scaffold-eth loogies-svg-nft 项目完整指南

注:这篇文章是我投稿于“李大狗Leeduckgo”公众号的文章,原文地址:SVG NFT 全面实践 | Web3.0 dApp 开发(六)


loogies-svg-nft 是 scaffold-eth 提供的一个简单的 NFT 铸造和展示的项目,在本教程中,我们将带领大家一步步分析和实现这个项目。

由于项目的 loogies-svg-nft 分支与 master 分支在组件库和主页上有一些变化,故先将 master 分支代码与 loogies-svg-nft 分支进行了合并,解决冲突,得到一份基新组件库的全新的代码。可以参考项目地址: https://github.com/qiwihui/scaffold-eth.gitloogies-svg-nft 分支。本文以下内容将基于这些代码进行部署和分析。

本地运行和测试

首先我们先运行项目查看我们将要分析实现的功能。

本地运行

首先我们在本地运行项目:

clone 项目并切换到 loogies-svg-nft 分支:

git clone https://github.com/qiwihui/scaffold-eth.git loogies-svg-nft
cd loogies-svg-nft
git checkout loogies-svg-nft

安装依赖包

yarn install

运行前端

yarn start

在第二个终端窗口中,运行本地测试链

yarn chain

yarn-chain

在第三个终端窗口中,运行部署合约

yarn deploy

yarn-deploy

此时在浏览器中访问 http://localhost:3000 ,就可以看到程序了。

yarn-start

本地测试

  1. 首先在 MetaMask 钱包中添加本地网络,并切换到本地网络;

    • 网络名称: Localhost 8545
    • 新增 RPC URL: http://localhost:8545
    • 链 ID: 31337
    • Currency Symbol: ETH
  2. 创建一个新的本地钱包账号;

  3. 复制钱包地址,在页面左下角给这个地址发送一些测试 ETH;

  4. 点击在页面右上角 connect 连接钱包;

  5. 点击 Mint 铸造;

  6. 当交易成功后,可以看到新铸造的 NFT;

    nft-display

下面,我们开始对项目合约进行分析。

Loogies 合约分析

NFT 与 ERC721

NFT,全称为Non-Fungible Token,指非同质化代币,对应于以太坊上 ERC-721 标准。 一般在智能合约中,NFT 的定义包含 tokenIdtokenURI ,每一个 NFT 的 tokenId 是唯一的, tokenURI 对于保存了NFT的元数据,可以是图像URL、描述、属性等。如果一个 NFT 想在 NFT 市场上进行展示和销售,则 tokenURI 内容需要对应符合 NFT 市场的标准,比如,在 NFT 市场 OpenSea 元数据标准中,就指出了 NFT 展示需要设置的属性。

OpenSea 中 NFT 元数据与展示对应关系

OpenSea 中 NFT 元数据与展示对应关系

合约概览

loogies-svg-nft 项目的合约文件在 packages/hardhat/contracts/ 路径下,包含以下三个文件:

packages/hardhat/contracts/
├── HexStrings.sol
├── ToColor.sol
└── YourCollectible.sol
  • HexString.sol :生成地址字符串;
  • ToColor.sol:生成颜色编码字符串;
  • YourCollectible.solLoogies NFT的合约文件,主要功能涉及合约铸造和元数据生成。

合约的主要结构和方法为:

contract YourCollectible is ERC721, Ownable {

	// 构造函数
  constructor() public ERC721("Loogies", "LOOG") {
  }
  // 铸造 NFT
  function mintItem()
      public
      returns (uint256)
  {
    ...
  }
	// 获取 tokenId 对应 tokeURI
  function tokenURI(uint256 id) public view override returns (string memory) {
    ...
  }
	// 生成 tokenId 对应 svg 代码
  function generateSVGofTokenById(uint256 id) internal view returns (string memory) {
    ...
  }

	// 生成 tokenId 对应 svg 代码,主要用于绘制图像
  function renderTokenById(uint256 id) public view returns (string memory) {
    ...
  }

}

构造函数

constructor() public ERC721("Loogies", "LOOG") {
    // RELEASE THE LOOGIES!
  }

代币符号: Loogies

代币名称: LOOG

合约继承自 OpenZeppelin 的 ERC721.sol,这是 OpenZeppelin 提供的基本合约代码,可以方便开发者使用。

应用库函数

合约中分别对 uint256uint160bytes3 等应用了不同库函数,扩展对应功能:

// 使 uint256 具有 toHexString 功能
using Strings for uint256;
// 使 uint160 具有自定义 toHexString 功能
using HexStrings for uint160;
// 使 bytes3 可以方便生成前端颜色表示
using ToColor for bytes3;
// 计数功能
using Counters for Counters.Counter;

Mint 期限

以下代码是 Mint 时间限制:

uint256 mintDeadline = block.timestamp + 24 hours;

function mintItem()
      public
      returns (uint256)
  {
      require( block.timestamp < mintDeadline, "DONE MINTING");
...

合约在部署之后的24小时内可以铸造,超过24小时则会引发异常。这个机制类似于预售,由于这个合约比较简单,所以没有使用白名单机制,一般在实际情况,会使用预售和白名单的方式来控制 NFT 的发行。

Mint 铸造

铸造 NFT 其实就是在合约中设置两个信息:

  • tokenId 及其 owner
  • tokenId 及其 tokenURI

我们首先看铸造函数 mintItem

// 用于保存每一个铸造的 Loogies 的特征,其中,color 表示颜色,chubbiness 表示胖瘦
mapping (uint256 => bytes3) public color;
mapping (uint256 => uint256) public chubbiness;

...

function mintItem()
      public
      returns (uint256)
  {
      require( block.timestamp < mintDeadline, "DONE MINTING");
			// 每次铸造前自增 _tokenIds,确保 _tokenIds 唯一
      _tokenIds.increment();

      uint256 id = _tokenIds.current();
			// 铸造者与 tokenId 绑定
      _mint(msg.sender, id);
			// 随机生成对应 tokenId 的属性
      bytes32 predictableRandom = keccak256(abi.encodePacked( blockhash(block.number-1), msg.sender, address(this), id ));
      color[id] = bytes2(predictableRandom[0]) | ( bytes2(predictableRandom[1]) >> 8 ) | ( bytes3(predictableRandom[2]) >> 16 );
      chubbiness[id] = 35+((55*uint256(uint8(predictableRandom[3])))/255);

      return id;
  }

其中:

  • tokenId 在每次铸造时会自增,确保 tokenId 唯一;
  • _mint 函数绑定 tokenId 及其 owner
  • 每一个 tokenId 对应的属性通过随机方式生成,具体为:
    • 通过前一个区块的哈希( blockhash(block.number-1) ),当前铸造账户( msg.sender),合约地址( address(this) )和 tokenId 生成哈希 predictableRandom
    • 计算 NFT 颜色:按位或 predictableRandom 前三位得到颜色,颜色表示用 bytes3 表示,其中 bytes2(predictableRandom[0]) 对应最低位蓝色数值, ( bytes2(predictableRandom[1]) >> 8 )对应中间位绿色数值, ( bytes3(predictableRandom[2]) >> 16 ) 对应最高位红色数值;
    • 计算 NFT 胖瘦: 35+((55*uint256(uint8(predictableRandom[3])))/255);uint8(predictableRandom[3])介于0~255,故最小值为35,最大值为 35+55 = 90;

例如: color0x4cc4c1chubbiness 为 88 时对应的 NFT 图片为:

loogies-1

tokenURI 函数

函数 tokenURI 接受 tokenId 参数,返回编码之后的元数据字符串:

function tokenURI(uint256 id) public view override returns (string memory) {
			// 检查 id 是否存在
      require(_exists(id), "not exist");
      string memory name = string(abi.encodePacked('Loogie #',id.toString()));
      string memory description = string(abi.encodePacked('This Loogie is the color #',color[id].toColor(),' with a chubbiness of ',uint2str(chubbiness[id]),'!!!'));
      // 生成图片的svg base64 编码
			string memory image = Base64.encode(bytes(generateSVGofTokenById(id)));

      return
          string(
              abi.encodePacked(
                'data:application/json;base64,',
								// 通过 base64 编码元数据
                Base64.encode(
                    bytes(
                          abi.encodePacked(
                              '{"name":"',
                              name,
                              '", "description":"',
                              description,
                              '", "external_url":"https://burnyboys.com/token/',
                              id.toString(),
                              '", "attributes": [{"trait_type": "color", "value": "#',
                              color[id].toColor(),
                              '"},{"trait_type": "chubbiness", "value": ',
                              uint2str(chubbiness[id]),
                              '}], "owner":"',
                              (uint160(ownerOf(id))).toHexString(20),
                              '", "image": "',
                              'data:image/svg+xml;base64,',
                              image,
                              '"}'
                          )
                        )
                    )
              )
          );
  }
// 生成的 SVG 字符串
function generateSVGofTokenById(uint256 id) internal view returns (string memory) {
...
}

// 绘制图像
// Visibility is `public` to enable it being called by other contracts for composition.
function renderTokenById(uint256 id) public view returns (string memory) {
...
}

其中, generateSVGofTokenById 函数返回 tokenId 对应的颜色和胖瘦属性生成的 SVG 字符串, renderTokenById 用户绘制图像。

我们可以看到,NFT 元数据中包含的属性有:

  • name:名称
  • description: 描述
  • external_url:外部链接
  • attributes:属性
    • color 颜色
    • chubbiness:胖瘦
    • owner:所有者,以太坊地址16进制形式
    • image:图片对应 SVG 的 base64 编码

这里,我们通过实际数据了解一下什么是 SVG。tokenId 为 1 时对应的 tokenURI 结果为:

data:application/json;base64,eyJuYW1lIjoiTG9vZ2llICMxIiwiZGVzY3JpcHRpb24iOiJUaGlzIExvb2dpZSBpcyB0aGUgY29sb3IgIzRjYzRjMSB3aXRoIGEgY2h1YmJpbmVzcyBvZiA4OCEhISIsImV4dGVybmFsX3VybCI6Imh0dHBzOi8vYnVybnlib3lzLmNvbS90b2tlbi8xIiwiYXR0cmlidXRlcyI6W3sidHJhaXRfdHlwZSI6ImNvbG9yIiwidmFsdWUiOiIjNGNjNGMxIn0seyJ0cmFpdF90eXBlIjoiY2h1YmJpbmVzcyIsInZhbHVlIjo4OH1dLCJvd25lciI6IjB4MTY5ODQxYWEzMDI0Y2ZhNTcwMDI0ZWI3ZGQ2YmY1Zjc3NDA5MjA4OCIsImltYWdlIjoiZGF0YTppbWFnZS9zdmcreG1sO2Jhc2U2NCxQSE4yWnlCM2FXUjBhRDBpTkRBd0lpQm9aV2xuYUhROUlqUXdNQ0lnZUcxc2JuTTlJbWgwZEhBNkx5OTNkM2N1ZHpNdWIzSm5Mekl3TURBdmMzWm5JajQ4WnlCcFpEMGlaWGxsTVNJK1BHVnNiR2x3YzJVZ2MzUnliMnRsTFhkcFpIUm9QU0l6SWlCeWVUMGlNamt1TlNJZ2NuZzlJakk1TGpVaUlHbGtQU0p6ZG1kZk1TSWdZM2s5SWpFMU5DNDFJaUJqZUQwaU1UZ3hMalVpSUhOMGNtOXJaVDBpSXpBd01DSWdabWxzYkQwaUkyWm1aaUl2UGp4bGJHeHBjSE5sSUhKNVBTSXpMalVpSUhKNFBTSXlMalVpSUdsa1BTSnpkbWRmTXlJZ1kzazlJakUxTkM0MUlpQmplRDBpTVRjekxqVWlJSE4wY205clpTMTNhV1IwYUQwaU15SWdjM1J5YjJ0bFBTSWpNREF3SWlCbWFXeHNQU0lqTURBd01EQXdJaTgrUEM5blBqeG5JR2xrUFNKb1pXRmtJajQ4Wld4c2FYQnpaU0JtYVd4c1BTSWpOR05qTkdNeElpQnpkSEp2YTJVdGQybGtkR2c5SWpNaUlHTjRQU0l5TURRdU5TSWdZM2s5SWpJeE1TNDRNREEyTlNJZ2FXUTlJbk4yWjE4MUlpQnllRDBpT0RnaUlISjVQU0kxTVM0NE1EQTJOU0lnYzNSeWIydGxQU0lqTURBd0lpOCtQQzluUGp4bklHbGtQU0psZVdVeUlqNDhaV3hzYVhCelpTQnpkSEp2YTJVdGQybGtkR2c5SWpNaUlISjVQU0l5T1M0MUlpQnllRDBpTWprdU5TSWdhV1E5SW5OMloxOHlJaUJqZVQwaU1UWTRMalVpSUdONFBTSXlNRGt1TlNJZ2MzUnliMnRsUFNJak1EQXdJaUJtYVd4c1BTSWpabVptSWk4K1BHVnNiR2x3YzJVZ2NuazlJak11TlNJZ2NuZzlJak1pSUdsa1BTSnpkbWRmTkNJZ1kzazlJakUyT1M0MUlpQmplRDBpTWpBNElpQnpkSEp2YTJVdGQybGtkR2c5SWpNaUlHWnBiR3c5SWlNd01EQXdNREFpSUhOMGNtOXJaVDBpSXpBd01DSXZQand2Wno0OEwzTjJaejQ9In0=

通过 base64 解码 data:application/json;base64, 之后的字符串可以得到如下 json(以下 json 经过了格式化,方便阅读):

{
  "name": "Loogie #1",
  "description": "This Loogie is the color #4cc4c1 with a chubbiness of 88!!!",
  "external_url": "https://burnyboys.com/token/1",
  "attributes": [
    {
      "trait_type": "color",
      "value": "#4cc4c1"
    },
    {
      "trait_type": "chubbiness",
      "value": 88
    }
  ],
  "owner": "0x169841aa3024cfa570024eb7dd6bf5f774092088",
  "image": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBpZD0iZXllMSI+PGVsbGlwc2Ugc3Ryb2tlLXdpZHRoPSIzIiByeT0iMjkuNSIgcng9IjI5LjUiIGlkPSJzdmdfMSIgY3k9IjE1NC41IiBjeD0iMTgxLjUiIHN0cm9rZT0iIzAwMCIgZmlsbD0iI2ZmZiIvPjxlbGxpcHNlIHJ5PSIzLjUiIHJ4PSIyLjUiIGlkPSJzdmdfMyIgY3k9IjE1NC41IiBjeD0iMTczLjUiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlPSIjMDAwIiBmaWxsPSIjMDAwMDAwIi8+PC9nPjxnIGlkPSJoZWFkIj48ZWxsaXBzZSBmaWxsPSIjNGNjNGMxIiBzdHJva2Utd2lkdGg9IjMiIGN4PSIyMDQuNSIgY3k9IjIxMS44MDA2NSIgaWQ9InN2Z181IiByeD0iODgiIHJ5PSI1MS44MDA2NSIgc3Ryb2tlPSIjMDAwIi8+PC9nPjxnIGlkPSJleWUyIj48ZWxsaXBzZSBzdHJva2Utd2lkdGg9IjMiIHJ5PSIyOS41IiByeD0iMjkuNSIgaWQ9InN2Z18yIiBjeT0iMTY4LjUiIGN4PSIyMDkuNSIgc3Ryb2tlPSIjMDAwIiBmaWxsPSIjZmZmIi8+PGVsbGlwc2Ugcnk9IjMuNSIgcng9IjMiIGlkPSJzdmdfNCIgY3k9IjE2OS41IiBjeD0iMjA4IiBzdHJva2Utd2lkdGg9IjMiIGZpbGw9IiMwMDAwMDAiIHN0cm9rZT0iIzAwMCIvPjwvZz48L3N2Zz4="
}

我们对 image 字段进行解码并格式化就得到图片的 SVG:

<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
   <g id="eye1">
      <ellipse stroke-width="3" ry="29.5" rx="29.5" id="svg_1" cy="154.5" cx="181.5" stroke="#000" fill="#fff" />
      <ellipse ry="3.5" rx="2.5" id="svg_3" cy="154.5" cx="173.5" stroke-width="3" stroke="#000" fill="#000000" />
   </g>
   <g id="head">
      <ellipse fill="#4cc4c1" stroke-width="3" cx="204.5" cy="211.80065" id="svg_5" rx="88" ry="51.80065" stroke="#000" />
   </g>
   <g id="eye2">
      <ellipse stroke-width="3" ry="29.5" rx="29.5" id="svg_2" cy="168.5" cx="209.5" stroke="#000" fill="#fff" />
      <ellipse ry="3.5" rx="3" id="svg_4" cy="169.5" cx="208" stroke-width="3" fill="#000000" stroke="#000" />
   </g>
</svg>

SVG是一种用 XML 定义的语言,用来描述二维矢量及矢量/栅格图形。它可以任意放大图形显示,也不会牺牲图像质量,它可以使用代码进行描述,方便编辑,因此被广泛使用。

从上面的代码结合以下的图像可以看出,这个 SVG 包含如下内容:

  • 第一行为 XML 声明,标明版本和编码类型,之后是SVG 的宽度和高度;
  • eye1:由两个椭圆(ellipse)绘制的眼圈和黑色眼珠;
  • head:填充 #4cc4c1 颜色的椭圆作为身体;
  • eye2:与 eye1 一致,位置不同;

eye1headeye2依次叠加得到最终的图形:

loogies-1

辅助函数解析

  1. uint2struint 转变为字符串,例如 123 变为 '123'
function uint2str(uint _i) internal pure returns (string memory _uintAsString) {
      if (_i == 0) {
          return "0";
      }
      uint j = _i;
			// uint 位数
      uint len;
      while (j != 0) {
          len++;
          j /= 10;
      }
      bytes memory bstr = new bytes(len);
      uint k = len;
      while (_i != 0) {
          k = k-1;
			    // _i 个位数字
          uint8 temp = (48 + uint8(_i - _i / 10 * 10));
          bytes1 b1 = bytes1(temp);
          bstr[k] = b1;
          _i /= 10;
      }
      return string(bstr);
  }
  1. ToColor.sol 库:将 byte3 类型转换为前端颜色字符串,例如:输入 0x4cc4c1 输出 '4cc4c1'
library ToColor {
    bytes16 internal constant ALPHABET = '0123456789abcdef';

    function toColor(bytes3 value) internal pure returns (string memory) {
      bytes memory buffer = new bytes(6);
      for (uint256 i = 0; i < 3; i++) {
          buffer[i*2+1] = ALPHABET[uint8(value[i]) & 0xf];
          buffer[i*2] = ALPHABET[uint8(value[i]>>4) & 0xf];
      }
      return string(buffer);
    }
}
  1. HexStrings.sol 库:主要作用是将 uintlength 位提取,对应于生成公钥时截取前20位的功能: (*uint160*(ownerOf(id))).toHexString(20),此表达式生成对应 tokenId 所有者的地址。
library HexStrings {
    bytes16 internal constant ALPHABET = '0123456789abcdef';

    function toHexString(uint256 value, uint256 length) internal pure returns (string memory) {
        bytes memory buffer = new bytes(2 * length + 2);
        buffer[0] = '0';
        buffer[1] = 'x';
        for (uint256 i = 2 * length + 1; i > 1; --i) {
            buffer[i] = ALPHABET[value & 0xf];
            value >>= 4;
        }
        return string(buffer);
    }
}

至此,合约源码分析完成。

下面我们将对前端的逻辑进行简要分析,然后我们将一步步实现 NFT 铸造和展示的功能。将代码切换到前端代码提交之前,按照以下的步骤一步步添加功能。

git checkout a98156f6a03a0bc8fc98c8c77cef6fbf59f03b31

前端逻辑分析

项目前端文件在 packages/react-app 内,以下文章中涉及文件的位置都将在这个文件中寻找。

我们首先来看一下 src/App.jsx ,这是项目的主要页面,我们可以利用代码编辑器查看这个文件的主要部分:

Appjsx

其中包含的功能和组件包括:

  • Header:标题栏,显示标题
  • NetworkDisplay:所处网络状态
  • MenuSwitch:菜单切换
  • ThemeSwitch:右下角明暗主题切换
  • Account:右上角账户信息组件
  • 接下来的两个 Row 对应左下角的 Gas 显示、支持和本地的水龙头

下面我们主要看一下 NetworkDisplayAccount 的逻辑实现,以及 MenuSwitch 中的功能。

NetworkDisplay

组件位置: src/components/NetworkDisplay.jsx

主要包含两个功能:

  1. 显示当前所选择的网络名称;
  2. 如果当前钱包所在网络与项目中网络设置不一致,则提示警告,其中,当选择本地网络是,网络 ID 需要设置为 31337
function NetworkDisplay({
  NETWORKCHECK,
  localChainId,
  selectedChainId,
  targetNetwork,
  USE_NETWORK_SELECTOR,
  logoutOfWeb3Modal,
}) {
  let networkDisplay = "";
  if (NETWORKCHECK && localChainId && selectedChainId && localChainId !== selectedChainId) {
    const networkSelected = NETWORK(selectedChainId);
    const networkLocal = NETWORK(localChainId);
    if (selectedChainId === 1337 && localChainId === 31337) {
			// 提示错误的网络ID
			...
    } else {
			// 提示网络错误
			...
    }
  } else {
    networkDisplay = USE_NETWORK_SELECTOR ? null : (
      // 显示网络名称
      <div style={{ zIndex: -1, position: "absolute", right: 154, top: 28, padding: 16, color: targetNetwork.color }}>
        {targetNetwork.name}
      </div>
    );
  }

  console.log({ networkDisplay });

  return networkDisplay;
}

Account

组件位置: src/components/Account.jsx

主要包含两个功能:

  1. 显示当前钱包
  2. 显示钱包余额
  3. 显示 Connect 或者 Logout

其中,当用户点击 Connect 时,前端调用 loadWeb3Modal,代码如下,这个函数的需要功能是与MetaMask等钱包进行连接,并监听钱包的 chainChangedaccountsChangeddisconnect 事件,即当我们在钱包中切换网络,选择连接账户以及取消连接时对应修改显示状态。

  const loadWeb3Modal = useCallback(async () => {
		// 连接钱包
    const provider = await web3Modal.connect();
    setInjectedProvider(new ethers.providers.Web3Provider(provider));
		// 监听切换网络
    provider.on("chainChanged", chainId => {
      console.log(`chain changed to ${chainId}! updating providers`);
      setInjectedProvider(new ethers.providers.Web3Provider(provider));
    });
    // 监听切换账户
    provider.on("accountsChanged", () => {
      console.log(`account changed!`);
      setInjectedProvider(new ethers.providers.Web3Provider(provider));
    });
		// 监听断开连接
    // Subscribe to session disconnection
    provider.on("disconnect", (code, reason) => {
      console.log(code, reason);
      logoutOfWeb3Modal();
    });
    // eslint-disable-next-line
  }, [setInjectedProvider]);

同理,在连接钱包情况下,用户点击 Logout 会调用 logoutOfWeb3Modal 功能,

const logoutOfWeb3Modal = async () => {
		// 清楚缓存的网络提供商,并断开连接
    await web3Modal.clearCachedProvider();
    if (injectedProvider && injectedProvider.provider && typeof injectedProvider.provider.disconnect == "function") {
      await injectedProvider.provider.disconnect();
    }
    setTimeout(() => {
      window.location.reload();
    }, 1);
  };

这两个分别对应显示菜单和对应切换菜单功能,这些菜单包括:

  • App Home :项目希望我们将需要实现的功能放在这个菜单中,比如我们将要实现的 NFT 的铸造和展示功能;
  • Debug Contracts:调试自己编写的合约功能,将会根据合约的 ABI 文件 展示可以合约的状态变量和可以调用的函数;
  • Hints:编程提示
  • ExampleUI:示例UI,可以做为编程使用
  • Mainnet DAI:以太坊主网 DAI 的合约状态和可用函数,与 Debug Contracts 功能一直
  • Subgraph:使用 The Graph 协议对合约中的事件进行监听和查询。

调试信息

App.jsx 中还包含了打印当前页面状态的调试信息,可以在开发的过程中实时查看当前状态变量。

  //
  // 🧫 DEBUG 👨🏻‍🔬
  //
  useEffect(() => {
    if (
      DEBUG &&
      mainnetProvider &&
      address &&
      selectedChainId &&
      yourLocalBalance &&
      yourMainnetBalance &&
      readContracts &&
      writeContracts &&
      mainnetContracts
    ) {
      console.log("_____________________________________ 🏗 scaffold-eth _____________________________________");
      console.log("🌎 mainnetProvider", mainnetProvider);
      console.log("🏠 localChainId", localChainId);
      console.log("👩‍💼 selected address:", address);
      console.log("🕵🏻‍♂️ selectedChainId:", selectedChainId);
      console.log("💵 yourLocalBalance", yourLocalBalance ? ethers.utils.formatEther(yourLocalBalance) : "...");
      console.log("💵 yourMainnetBalance", yourMainnetBalance ? ethers.utils.formatEther(yourMainnetBalance) : "...");
      console.log("📝 readContracts", readContracts);
      console.log("🌍 DAI contract on mainnet:", mainnetContracts);
      console.log("💵 yourMainnetDAIBalance", myMainnetDAIBalance);
      console.log("🔐 writeContracts", writeContracts);
    }
  }, [
    mainnetProvider,
    address,
    selectedChainId,
    yourLocalBalance,
    yourMainnetBalance,
    readContracts,
    writeContracts,
    mainnetContracts,
    localChainId,
    myMainnetDAIBalance,
  ]);

查看完主页的基本功能,下面我们开始实现 NFT 铸造和展示 NFT 列表这两个功能。

NFT 功能实现

我们将主要实现以下三个部分功能:

  • 铸造 NFT;
  • 展示 NFT 列表;
  • 展示 NFT 合约接口列表。

铸造 NFT

首先我们找到 App Home 对应使用的组件,从下面的代码中可以看到,对应使用 Home 组件,所在位置为 src/views/Home.jsx

    ...
    <Switch>
        <Route exact path="/">
          {/* pass in any web3 props to this Home component. For example, yourLocalBalance */}
          <Home yourLocalBalance={yourLocalBalance} readContracts={readContracts} />
        </Route>
    ....

删除 Home.jsx 中内容,添加以下 Mint 按钮:

import React, { useState } from "react";
import { Button, Card, List } from "antd";

function Home({ 
  isSigner,
  loadWeb3Modal,
  tx,
  writeContracts,
}) {

  return (
    <div>
      {/* Mint button */}
      <div style={{ maxWidth: 820, margin: "auto", marginTop: 32, paddingBottom: 32 }}>
        {isSigner?(
          <Button type={"primary"} onClick={()=>{
            tx( writeContracts.YourCollectible.mintItem() )
          }}>MINT</Button>
        ):(
          <Button type={"primary"} onClick={loadWeb3Modal}>CONNECT WALLET</Button>
        )}
      </div>
    </div>
  );
}

export default Home;

同时将 Switch 中对应组件使用修改为:

      ...
      <Switch>
        <Route exact path="/">
          {/* pass in any web3 props to this Home component. For example, yourLocalBalance */}
          <Home
            isSigner={userSigner}
            loadWeb3Modal={loadWeb3Modal}
            tx={tx}
            writeContracts={writeContracts}
          />
       ...

效果图为:

mint-button

点击 Mint 之后,我们可以看到交易成功发出,这时,虽然我们成功 mint 了 NFT,但是我们还需要添加列表来展示我们的 NFT。

展示 NFT 列表

添加列表展示,其中包含 NFT 的转移功能可以将对应的 NFT 发送给其他地址。

import React, { useState } from "react";
import { Button, Card, List } from "antd";
import { useContractReader } from "eth-hooks";
import { Address, AddressInput} from "../components";

function Home({ 
  isSigner,
  loadWeb3Modal,
  yourCollectibles,
  address,
  blockExplorer,
  mainnetProvider,
  tx,
  readContracts,
  writeContracts,
}) {
  const [transferToAddresses, setTransferToAddresses] = useState({});

  return (
    <div>
      {/* Mint 按钮 */}
			...
			{/* 列表 */}
      <div style={{ width: 820, margin: "auto", paddingBottom: 256 }}>
        <List
          bordered
          dataSource={yourCollectibles}
          renderItem={item => {
            const id = item.id.toNumber();
            console.log("IMAGE",item.image)
            return (
              <List.Item key={id + "_" + item.uri + "_" + item.owner}>
                <Card
                  title={
                    <div>
                      <span style={{ fontSize: 18, marginRight: 8 }}>{item.name}</span>
                    </div>
                  }
                >
                  <a href={"https://opensea.io/assets/"+(readContracts && readContracts.YourCollectible && readContracts.YourCollectible.address)+"/"+item.id} target="_blank">
                  <img src={item.image} />
                  </a>
                  <div>{item.description}</div>
                </Card>
								{/* NFT 转移 */}
                <div>
                  owner:{" "}
                  <Address
                    address={item.owner}
                    ensProvider={mainnetProvider}
                    blockExplorer={blockExplorer}
                    fontSize={16}
                  />
                  <AddressInput
                    ensProvider={mainnetProvider}
                    placeholder="transfer to address"
                    value={transferToAddresses[id]}
                    onChange={newValue => {
                      const update = {};
                      update[id] = newValue;
                      setTransferToAddresses({ ...transferToAddresses, ...update });
                    }}
                  />
                  <Button
                    onClick={() => {
                      console.log("writeContracts", writeContracts);
                      tx(writeContracts.YourCollectible.transferFrom(address, transferToAddresses[id], id));
                    }}
                  >
                    Transfer
                  </Button>
                </div>
              </List.Item>
            );
          }}
        />
      </div>
      {/* 信息提示 */}
      <div style={{ maxWidth: 820, margin: "auto", marginTop: 32, paddingBottom: 256 }}>
        🛠 built with <a href="https://github.com/austintgriffith/scaffold-eth" target="_blank">🏗 scaffold-eth</a>
        🍴 <a href="https://github.com/austintgriffith/scaffold-eth" target="_blank">Fork this repo</a> and build a cool SVG NFT!
      </div>
    </div>
  );
}

export default Home;

对应组件使用修改为:

      ...
      <Switch>
        <Route exact path="/">
          {/* pass in any web3 props to this Home component. For example, yourLocalBalance */}
          <Home
            isSigner={userSigner}
            loadWeb3Modal={loadWeb3Modal}
            yourCollectibles={yourCollectibles}
            address={address}
            blockExplorer={blockExplorer}
            mainnetProvider={mainnetProvider}
            tx={tx}
            writeContracts={writeContracts}
            readContracts={readContracts}
          />
       ...

效果图为:

nft-display-list

但是我们发现,当我们再次 mint 时,列表并不会更新,还是原来的样子,因此我们需要在 App.jsx 中添加事件监听,一旦我们铸造 NFT 之后,列表将刷新:

  // 跟踪当前 NFT 数量
  const balance = useContractReader(readContracts, "YourCollectible", "balanceOf", [address]);
  console.log("🤗 balance:", balance);

  const yourBalance = balance && balance.toNumber && balance.toNumber();
  const [yourCollectibles, setYourCollectibles] = useState();
  //
  // 🧠 这个 effect 会在 balance 变化时更新 yourCollectibles 
  //
  useEffect(() => {
    const updateYourCollectibles = async () => {
      const collectibleUpdate = [];
      for (let tokenIndex = 0; tokenIndex < balance; tokenIndex++) {
        try {
          console.log("GEtting token index", tokenIndex);
          const tokenId = await readContracts.YourCollectible.tokenOfOwnerByIndex(address, tokenIndex);
          console.log("tokenId", tokenId);
          const tokenURI = await readContracts.YourCollectible.tokenURI(tokenId);
          const jsonManifestString = atob(tokenURI.substring(29))
          console.log("jsonManifestString", jsonManifestString);

          try {
            const jsonManifest = JSON.parse(jsonManifestString);
            console.log("jsonManifest", jsonManifest);
            collectibleUpdate.push({ id: tokenId, uri: tokenURI, owner: address, ...jsonManifest });
          } catch (e) {
            console.log(e);
          }

        } catch (e) {
          console.log(e);
        }
      }
      setYourCollectibles(collectibleUpdate.reverse());
    };
    updateYourCollectibles();
  }, [address, yourBalance]);

此时,当我们再次 Mint 时,就是自动更新列表,显示最新铸造的 NFT 了。

展示 NFT 合约接口列表

这个功能比较简单,只需要修改对应 debug 部分即可:

      <Route exact path="/debug">
          {/*
                🎛 this scaffolding is full of commonly used components
                this <Contract/> component will automatically parse your ABI
                and give you a form to interact with it locally
            */}

          <Contract
            name="YourCollectible"
            price={price}
            signer={userSigner}
            provider={localProvider}
            address={address}
            blockExplorer={blockExplorer}
            contractConfig={contractConfig}
          />

更新之后,可以在 Debug Contracts 菜单下看到合约的可以调用的函数。

contract-funcs

至此,我们就完成了一个简单 NFT 铸造和展示的 DApp 了。

总结

通过这个项目,我们可以学习并了解以下知识:

  1. NFT 合约基本内容以及如何在 Opensea 等市场中展示 NFT;
  2. 前端如何连接诸如 MetaMask 等钱包;
  3. 前端如何调用合约函数。

View on GitHub