65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Hardhat 和 React Typescript 构建 NFT 收藏 Web3 应用程序

starIconstarIconstarIconstarIconstarIcon

5.00/5 (17投票s)

2022 年 8 月 5 日

CPOL

15分钟阅读

viewsIcon

29277

downloadIcon

278

使用 React 和 hardhat typescript 从头开始构建 NFT 合约 Web3 应用程序

引言

在本文中,我将向您展示如何创建一个 NFT 合约,以及如何构建一个 React Web3 应用程序来实现 NFT 收藏品加载、NFT 铸造和提现等功能。

什么是以太坊?

以太坊是区块链领域的一个重量级名字。它是第二大区块链平台,也是开发基于区块链的去中心化应用程序的首选。以太坊重新定义了区块链技术的吸引力,并向世界展示了它不仅仅是一个点对点现金系统。尽管新来者大多将比特币与区块链技术联系起来,但加密货币只是该技术的一个方面。但以太坊是一个可编程的区块链平台,而且是开源的。因此,用户可以开发自己喜欢的各种应用程序。以太坊创新了许多高级概念,例如:去中心化应用程序、智能合约、虚拟机、ERC 代币等。

什么是 ERC?

ERC 基本上是指以太坊意见征求(Ethereum Request for Comments),其基本作用是为以太坊提供功能。它包含一套创建以太坊代币的标准规则。ERC 代币中的指令概述了代币的销售、购买、单位限制和存在性。

ERC-20 和 ERC721 代币是 ERC 代币标准的初始类型,它们在定义以太坊生态系统功能方面起着重要作用。您可以将它们视为在以太坊区块链上创建和发布智能合约的标准。还值得注意的是,人们可以投资于使用智能合约创建的代币化资产或智能资产。ERC 更像是一个模板或格式,所有开发人员在开发智能合约时都应遵循。

ERC20 与 ERC721 的区别在于同质化与非同质化之间的差异。同质化资产是可以与其他同类实体交换的资产。另一方面,非同质化资产则相反,不能相互交换。例如,房屋可以很容易地被视为非同质化资产,因为它具有一些独特的属性。在加密货币领域,以数字形式表示资产肯定需要考虑同质化和非同质化的方面。

什么是 NFT 智能合约?

NFT 智能合约是 ERC721 代币。它是一种非同质化代币,称为 NFT,是一种数字资产,在区块链上代表现实世界中的艺术品、音乐和视频等物品。NFT 使用加密货币区块链上记录和验证的标识码和元数据,这使得区块链上表示的每个 NFT 都成为一个唯一的资产。与同样记录在区块链上的加密货币不同,NFT 不能等价地交易或交换,因此它们是非同质化的。智能合约是存在于区块链中的程序。这使得网络能够存储 NFT 交易中指示的信息。智能合约是自执行的,可以检查合同条款是否已满足,并能在无需中介或中央机构的情况下执行条款。

什么是 Solidity?

Solidity 是一种用于实现智能合约的面向对象的、高级语言。智能合约是控制以太坊状态内账户行为的程序。

Solidity 是静态类型的,支持继承、库和其他功能。使用 Solidity,您可以创建用于投票、众筹、盲拍和多重签名钱包等用途的合约。

什么是 Hardhat?

Hardhat 是以太坊开发环境。轻松部署您的合约,运行测试并调试 Solidity 代码,而无需处理实际的实时环境。Hardhat Network 是一个专为开发设计的本地以太坊网络。

必备组件

NFT 代币具有元数据(图像和属性)。它可以存储在 IPFS(星际文件系统)或链上。我们将 NFT 元数据存储在 IPFS 中,格式为 SVG。SVG 图像格式受 Opensea 的店面支持,在合约部署到 ethereum 链的 mainnettestnet 后,您可以在其中查看 NFT。

在创建 NFT 合约之前,我们需要将 SVG 图像上传到 IPFS。感谢 Pinata 网站,它使这项工作变得非常容易。 访问 Pinata 网站并创建一个帐户,如果您上传的数据高达 1 GB,它是免费的。注册后,您将进入 Pin Manager 窗口。使用界面上传您的文件夹。上传文件夹后,您将获得一个与之关联的 CID。它看起来应该像这样:

对于我的文件夹,CIDQmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg。因此,该文件夹的 IPFS URL 是 ipfs://QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg

此 URL 不会在浏览器中打开。要打开它,您可以使用 IPFS 网关的 HTTP URL。尝试访问此链接:https://ipfs.io/ipfs/QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg/0001.svg。它将显示我命名为 *00001.png* 并上传到我的文件夹的图像。

创建 React Typescript Web3 应用程序

在构建智能合约时,您需要一个开发环境来部署合约、运行测试和调试 Solidity 代码,而无需处理实时环境。

React 应用是编译 Solidity 代码并生成可在客户端应用程序中运行的代码的理想应用。

Hardhat 是一个 Ethereum 开发环境和框架,专为全栈开发而设计。

ethers.js 是一个完整而紧凑的库,用于从 ReactVueAngular 等客户端应用程序与 Ethereum Blockchain 及其生态系统进行交互。

MetaMask 帮助管理账户,并将当前用户连接到区块链。连接 MetaMask 钱包后,您可以与全局可用的 Ethereum API (window.ethereum) 交互,该 API 标识了 Web3 兼容浏览器(如 MetaMask 用户)的用户。

首先,我们创建一个 typescript React 应用。

npx create-react-app react-web3-ts --template typescript

接下来,进入新目录并安装 ethers.jshardhat

npm install ethers hardhat chai @nomiclabs/hardhat-ethers

首先删除 React 应用程序文件夹中的 README.mdtsconfig.json,否则会出现冲突。运行以下命令:

npx hardhat

然后选择“创建 TypeScript 项目”。

它会提示您安装一些依赖项。

Hardhat 已安装。我们只需要安装 hardhat-toolbox

npm install --save-dev @nomicfoundation/hardhat-toolbox@^1.0.1

现在使用 VS Code 打开“react-web3-ts”文件夹,它应该如下所示:

有一个示例合约 Lock.sol

从我们的 React 应用,我们将通过 ethers.js 库、合约地址以及 hardhat 从合约生成的 ABI 的组合来与智能合约进行交互。

什么是 ABIABI 是应用程序二进制接口(Application Binary Interface)的缩写。您可以将其视为客户端应用程序与部署了您将与之交互的智能合约的以太坊区块链之间的接口。

ABIs 通常由 HardHat 等开发框架从 Solidity 智能合约编译而来。您也经常可以在 Etherscan 上找到智能合约的 ABIs

Hardhat-toolbox 包含 type-chain。默认情况下,typechain 在根文件夹下的 typechain-types 中生成类型。这会在我们的 React 客户端编码中引起问题。React 会抱怨我们应该只从 src 文件夹导入类型。所以我们在 hardhat.config.ts 中进行更改。

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

const config: HardhatUserConfig = {
  solidity: "0.8.9",
  networks: {
    hardhat: {
      chainId: 1337
    }
  },
  typechain: {
    outDir: 'src/typechain-types',
    target: 'ethers-v5',
    alwaysGenerateOverloads: false, // should overloads with full signatures 
                                    // like deposit(uint256) be generated always, 
                                    // even if there are no overloads?
    externalArtifacts: ['externalArtifacts/*.json'], // optional array of glob 
    // patterns with external artifacts to process (for example external libs 
                                    // from node_modules)
    dontOverrideCompile: false      // defaults to false
  }
};

export default config;

hardhat.config.ts 中,我添加了一个本地网络,请注意,如果您想连接 MetaMask,您必须将 chain Id 设置为 1337。此外,我们添加了一个自定义的 typechain 配置,其输出文件夹为 src/typechain-types

编译 ABI

在 VS Code 终端中运行以下命令:

npx hardhat compile

现在,您应该在 src 目录中看到一个名为 typechain-types 的新文件夹。您可以在其中找到示例合约 Lock.sol 的所有类型和接口。

部署和使用本地网络/区块链

要部署到本地网络,您首先需要启动本地测试节点。

npx hardhat node

您应该看到一系列地址和私钥。

这些是为我们创建的 20 个测试账户和地址,可用于部署和测试我们的智能合约。每个账户还加载了 10,000 个假以太币。稍后,我们将学习如何将测试账户导入 MetaMask,以便可以使用它。

现在我们可以运行部署脚本,并给 CLI 一个标志,表示我们想部署到本地网络。在 VS Code 中,打开另一个终端运行以下命令:

npx hardhat run scripts/deploy.ts --network localhost

Lock 合约已部署到 0x5FbDB2315678afecb367f032d93F642f64180aa3

好的。示例合约已编译并部署。这验证了我们的 Web3 开发环境已设置。现在我们运行“hardhat clean”来清理示例合约,并开始编写我们自己的 NFT Collectible 智能合约。

npx hardhat clean

编写 NFT Collectible 智能合约

现在我们安装 OpenZeppelin 合约包。这将使我们能够访问 ERC721 合约(NFT 的标准)以及我们稍后会遇到的几个辅助库。

npm install @openzeppelin/contracts

删除 contracts 文件夹中的 lock.sol。创建一个名为 NFTCollectible.sol 的新文件。

我们将使用 Solidity v8.4。我们的合约将继承自 OpenZeppelinERC721EnumerableOwnable 合约。前者实现了 ERC721 (NFT) 标准的默认实现,并提供了一些在处理 NFT 收藏品时有用的辅助函数。后者允许我们将管理权限添加到我们合约的某些方面。

除了上述之外,我们还将使用 OpenZeppelinSafeMathCounters 库来安全地处理无符号整数算术(通过防止溢出)和代币 ID。

这就是我们的合约的样子:

// contracts/NFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

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

contract NFTCollectible is ERC721Enumerable, Ownable {
    using SafeMath for uint256;
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIds;

    uint256 public constant MAX_SUPPLY = 100;
    uint256 public constant PRICE = 0.01 ether;
    uint256 public constant MAX_PER_MINT = 5;

    string public baseTokenURI;

    constructor(string memory baseURI) ERC721("NFT Collectible", "NFTC") {
        setBaseURI(baseURI);
    }

    function setBaseURI(string memory _baseTokenURI) public onlyOwner {
        baseTokenURI = _baseTokenURI;
    }

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

    function reserveNFTs() public onlyOwner {
        uint256 totalMinted = _tokenIds.current();
        require(totalMinted.add(10) < MAX_SUPPLY, "Not enough NFTs");
        for (uint256 i = 0; i < 10; i++) {
            _mintSingleNFT();
        }
    }

    function mintNFTs(uint _count) public payable {
        uint totalMinted = _tokenIds.current();

        require(totalMinted.add(_count) <= MAX_SUPPLY, "Not enough NFTs left!");
        require(_count >0 && _count <= MAX_PER_MINT, 
        "Cannot mint specified number of NFTs.");
        require(msg.value >= PRICE.mul(_count), "Not enough ether to purchase NFTs.");

        for (uint i = 0; i < _count; i++) {
            _mintSingleNFT();
        }
    }

    function _mintSingleNFT() private {
        uint256 newTokenID = _tokenIds.current();
        _safeMint(msg.sender, newTokenID);
        _tokenIds.increment();
    }

    function tokensOfOwner(address _owner) external view returns (uint[] memory) {

        uint tokenCount = balanceOf(_owner);
        uint[] memory tokensId = new uint256[](tokenCount);

        for (uint i = 0; i < tokenCount; i++) {
            tokensId[i] = tokenOfOwnerByIndex(_owner, i);
        }
        return tokensId;
    }

    function withdraw() public payable onlyOwner {
        uint balance = address(this).balance;
        require(balance > 0, "No ether left to withdraw");

        (bool success, ) = (msg.sender).call{value: balance}("");
        require(success, "Transfer failed.");
    }
}

我们在构造函数调用中设置了 baseTokenURI。我们还调用了父构造函数,并为我们的 NFT 收藏品设置了名称和符号。

我们的 NFT JSON 元数据可在本文开头提到的 IPFS URL 找到。

当我们将此设置为基本 URI 时,OpenZeppelin 的实现会自动推断每个代币的 URI。它假设代币 1 的元数据将在 ipfs://QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg/1 处可用,代币 2 的元数据将在 ipfs://QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg/2 处可用,依此类推。

但是,我们需要告诉我们的合约,我们定义的 baseTokenURI 变量是合约必须使用的基本 URI。为此,我们覆盖了一个名为 _baseURI() 的空函数,并使其返回 baseTokenURI

铸造 NFT 函数

现在让我们将注意力转向主要的铸造 NFT 函数。我们的用户和客户将在想要购买和铸造我们收藏中的 NFT 时调用此函数。

任何人都可以通过支付所需的 ether + gas 来铸造一定数量的 NFT,因为他们将 ether 发送到此函数,因此我们必须将其标记为 payable。

在允许铸造发生之前,我们需要进行三项检查:

  1. 收藏品中仍有足够的 NFT 供调用者铸造所请求的数量。
  2. 调用者请求铸造的数量大于 0 且小于每次交易允许铸造的最大 NFT 数量。
  3. 调用者发送的 ether 数量足以铸造所请求的 NFT 数量。

提现余额函数

如果我们无法提取发送到合约的 ether,那么我们迄今为止所做的所有努力都将白费。

让我们编写一个函数来允许我们提取合约的全部余额。这显然将被标记为 onlyOwner

编译合约

首先,编译我们的新智能合约。

npx hardhat compile

编译完成后,新合约的类型将在 src/typechain-types 文件夹中生成。

您可以在 NFTCollectible__factory.ts 中找到新合约的 ABI。

测试合约

删除 test 文件夹中的 Lock.ts 并添加 NFTCollectible.ts。让我们从以下代码开始。

import { expect } from "chai";
import { ethers } from "hardhat";
import { NFTCollectible } from "../src/typechain-types/contracts/NFTCollectible";
import type { SignerWithAddress   } from "@nomiclabs/hardhat-ethers/signers";

describe("NFTCollectible", function () {
  let contract : NFTCollectible;
  let owner : SignerWithAddress;
  let addr1 : SignerWithAddress;
}

因为每个测试用例都需要部署合约。所以我们将部署作为 beforeEach 编写。

beforeEach(async function () {
    // Get the ContractFactory and Signers here.
    [owner, addr1] = await ethers.getSigners();
    const contractFactory = await ethers.getContractFactory("NFTCollectible");
    contract = await contractFactory.deploy("baseTokenURI");
    await contract.deployed();
  }); beforeEach(async function () {
    // Get the ContractFactory and Signers here.
    [owner, addr1] = await ethers.getSigners();
    const contractFactory = await ethers.getContractFactory("NFTCollectible");
    contract = await contractFactory.deploy("baseTokenURI");
    await contract.deployed();
});

现在我们添加交易测试用例。

  • reserveNFTs 为所有者预留 10 个 NFT。
    it("Reserve NFTs should 10 NFTs reserved", async function () {
          let txn = await contract.reserveNFTs();
          await txn.wait();
          expect(await contract.balanceOf(owner.address)).to.equal(10);
    });
  • NFT 的价格是 0.01ETH,所以需要支付 0.03ETH 来铸造 3 个 NFT。
    it("Sending 0.03 ether should mint 3 NFTs", async function () {
          let txn = await contract.mintNFTs(3, 
                    { value: ethers.utils.parseEther('0.03') });
          await txn.wait();
          expect(await contract.balanceOf(owner.address)).to.equal(3);
    });
  • 铸造 NFT 时,铸造者支付智能合约和 gas 费用。gas 费用支付给矿工,但加密货币支付给合约而不是所有者。

    it("Withdrawal should withdraw the entire balance", async function () {
          let provider = ethers.provider
          const ethBalanceOriginal = await provider.getBalance(owner.address);
          console.log("original eth balanace %f", ethBalanceOriginal);
          let txn = await contract.connect(addr1).mintNFTs(1, 
                    { value: ethers.utils.parseEther('0.01') });
          await txn.wait();
          
          const ethBalanceBeforeWithdrawal = await provider.getBalance(owner.address);
          console.log("eth balanace before withdrawal %f", ethBalanceBeforeWithdrawal);
          txn = await contract.connect(owner).withdraw();
          await txn.wait();
          const ethBalanceAfterWithdrawal = await provider.getBalance(owner.address);
          console.log("eth balanace after withdrawal %f", ethBalanceAfterWithdrawal);
          expect(ethBalanceOriginal.eq(ethBalanceBeforeWithdrawal)).to.equal(true);
          expect(ethBalanceAfterWithdrawal.gt
                (ethBalanceBeforeWithdrawal)).to.equal(true);
    });

运行测试。

npx hardhat test

在本地部署合约

scripts\deplot.ts 文件中更改 main 函数。

main 函数中,将 Lock 合约部署替换为我们的 NTFCollectible 合约部署。请注意,我们需要将 NFT 收藏的基础 URL 传递给合约的构造函数。

const baseTokenURI = "ipfs://QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg/"; 
// Get contract that we want to deploy
const contractFactory = await ethers.getContractFactory("NFTCollectible");
// Deploy contract with the correct constructor arguments
const contract = await contractFactory.deploy(baseTokenURI);

// Wait for this transaction to be mined
await contract.deployed();

console.log("NFTCollectible deployed to:", contract.address);

在 VS Code 中打开终端运行:

npx hardhat node

打开另一个终端运行部署命令。

npx hardhat run scripts/deploy.ts --network localhost

我们的合约已部署到地址 0x5FbDB2315678afecb367f032d93F642f64180aa3

React 客户端

我们已经部署了合约。接下来,我将向您展示如何构建一个 React 客户端来使用此智能合约提供的功能。

Material UI

Material UI 是一个开源的 React 组件库,实现了 Google 的 Material Design

它包含一个全面的预构建组件集合,这些组件可以直接用于生产环境。

Material UI 在设计上就很美观,并提供了一系列自定义选项,可以轻松地在我们的组件之上实现您自己的自定义设计系统。

安装 Material UI。

npm install @mui/material @emotion/react @emotion/styled

安装 Material UI 图标。

npm install @mui/icons-material

MUI 拥有各种各样的组件。我们将使用 App Bar、Box、Stack、Modal 和 Image List。

  • App Bar

    App Bar 显示与当前屏幕相关的信息和操作。

  • Box

    Box 组件作为大多数 CSS 工具性需求的包装组件。

  • 堆栈

    Stack 组件通过可选的间距和/或每个子元素之间的分隔符,来管理垂直或水平轴上直接子元素布局。

  • Modal

    Modal 组件为创建对话框、弹出窗口、灯箱或其他任何内容提供了坚实的基础。

  • Image List

    Image List 在有组织的网格中显示图像集合。

目前,tsconfig.json 是由 hardhat typescript 创建的。它并未完全覆盖 react typescript。将 tsconfig.json 更新如下:

{
  "compilerOptions": {
    "target": "es2021",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "commonjs",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "outDir": "dist",
    "sourceMap": true,
    "jsx": "react-jsx"
  },
  "include": ["./scripts", "./test", "./src/typechain-types"],
  "files": ["./hardhat.config.ts"]
}

MetaMask.svg 下载到 src 文件夹,我们将其用作 logo。

src 文件夹下添加 Demo.tsx 文件,并复制以下代码。我们从一个基本的 App Bar 开始。

import * as React from 'react';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import CssBaseline from '@mui/material/CssBaseline';
import Container from '@mui/material/Container';
import Stack from '@mui/material/Stack';
import Avatar from '@mui/material/Avatar';
import logo from './metamask.svg';

function Demo() {
  return (
    <React.Fragment>
      <CssBaseline />
      <AppBar>
        <Toolbar>
          <Stack direction="row" spacing={2}>
            <Typography variant="h3" component="div">
              NFT Collection
            </Typography>
            <Avatar alt="logo" src={logo} sx={{ width: 64, height: 64 }} />
          </Stack>
        </Toolbar>
      </AppBar>
      <Toolbar />
      <Container>
      </Container>
    </React.Fragment>
  );
}

export default Demo;

然后更改 index.tsx 以加载 Demo 而不是 App。

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import Demo from './Demo';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <Demo />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

运行它。

npm start

连接钱包

让我们仔细检查一下我们部署 NFT 收藏合约的地址。它是 0x5FbDB2315678afecb367f032d93F642f64180aa3。首先定义一个 const

const contractAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3";

然后定义 IWallet 接口。

interface IWallet {
  iconColor: string;
  connectedWallet: string;
  contractAddress: string;
  contractSymbol: string;
  contractBaseTokenURI: string;
  contractOwnerAddress: string;
  contractPrice: string;
  isOwner: boolean;
}

我们需要使用 useState hook 来初始化和更新 IWallet 实例。React 从 16.8 版本引入了 Hooks。useState 是一个内置 hook,它允许您在函数组件中使用本地状态。您将初始状态传递给此函数,它返回一个具有当前状态值(不一定是初始状态)的变量和另一个用于更新该值的函数。

const [state, setState] = React.useState<IWallet>({
    iconColor: "disabled",
    connectedWallet: "",
    contractSymbol: "",
    contractAddress: "",
    contractBaseTokenURI: "",
    contractOwnerAddress: "",
    contractPrice: "",
    isOwner: false
});

导入 ethersNFTCollectible__factory

import { BigNumber, ethers } from "ethers";
import { NFTCollectible__factory } from 
'./typechain-types/factories/contracts/NFTCollectible__factory'

现在编写连接钱包函数。

const connectWallet = async () => {
    try {
      console.log("connect wallet");
      const { ethereum } = window;

      if (!ethereum) {
        alert("Please install MetaMask!");
        return;
      }

      const accounts = await ethereum.request({
        method: "eth_requestAccounts",
      });
      console.log("Connected", accounts[0]);

      const provider = new ethers.providers.Web3Provider(ethereum);
      const contract = NFTCollectible__factory.connect
                       (contractAddress, provider.getSigner());
      //const contract = new ethers.Contract
      //(contractAddress, NFTCollectible__factory.abi, signer) as NFTCollectible;
      const ownerAddress = await contract.owner();
      const symbol = await contract.symbol();
      const baseTokenURI = await contract.baseTokenURI();
      const balance = await (await contract.balanceOf(accounts[0])).toNumber();
      const ethBalance = ethers.utils.formatEther
                         (await provider.getBalance(accounts[0]));
      const isOwner = (ownerAddress.toLowerCase() === accounts[0].toLowerCase());
      const price = ethers.utils.formatEther(await contract.PRICE());
      setState({
        iconColor: "success",
        connectedWallet: accounts[0],
        contractSymbol: symbol,
        contractAddress: contract.address,
        contractBaseTokenURI: baseTokenURI,
        contractOwnerAddress: ownerAddress,
        contractPrice: `${price} ETH`,
        isOwner: isOwner
      });

      console.log("Connected", accounts[0]);
    } catch (error) {
      console.log(error);
    }
};

您可以在此处看到,必须安装 MetaMask 扩展,否则您将无法从 window 获取 Ethereum 对象。进行 UI 更改,添加连接按钮以及已连接账户、合约地址、合约基础代币 URI 文本字段。此外,使用 Account Circle 图标来指示连接状态。

<Stack direction="row" spacing={2} sx={{ margin: 5 }}>
  <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
    <AccountCircle color={state.iconColor} sx={{ mr: 1, my: 0.5 }} />
    <TextField id="wallet_address" label="Connected Account" 
    sx={{ width: 300 }} variant="standard" value={state.connectedWallet}
      inputProps={{ readOnly: true, }}
    />
  </Box>
  <TextField id="contract_symbol" label="Contract Symbol" 
  vari-ant="standard" value={state.contractSymbol}
    inputProps={{ readOnly: true, }}
  />
  <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
    <TextField id="contract_address" label="Contract Address" 
    sx={{ width: 400 }} variant="standard" value={state.contractAddress}
      inputProps={{ readOnly: true, }}
    />
  </Box>
  <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
    <TextField id="contract_baseURI" label="Contract Base Token URI" 
    sx={{ width: 500 }} variant="standard" value={state.contractBaseTokenURI}
      inputProps={{ readOnly: true, }}
    />
  </Box>
</Stack>

以下是如何使用 Stack。Stack 有两个方向:“row”和“column”。

运行我们的应用程序。

点击连接按钮。

哇哦,效果惊人!

加载 NFT 收藏

为图片 URL 集合添加状态 hook。

const [nftCollection, setNFTCollection] = React.useState<string[]>([]);

编写加载 NFT 收藏函数。

  const loadNFTCollection = async () => {
    try {
      console.log("load NFT collection");
      let baseURI: string = state.contractBaseTokenURI;
      baseURI = baseURI.replace("ipfs://", "https://gateway.pinata.cloud/ipfs/");
      setNFTCollection(
        [
          `${baseURI}0001.svg`,
          `${baseURI}0002.svg`,
          `${baseURI}0003.svg`,
          `${baseURI}0004.svg`,
        ]);
    } catch (error) {
      console.log(error);
    }
  };

导入 ImageListImageListItem

import ImageList from '@mui/material/ImageList';
import ImageListItem from '@mui/material/ImageListItem';

将图片列表与 NFT URL 集合绑定。

<ImageList sx={{ width: 500, height: 450 }} cols={3} rowHeight={164}>
  {nftCollection.map((item) => (
    <ImageListItem key={item}>
      <img
        src={`${item}?w=164&h=164&fit=crop&auto=format`}
        srcSet={`${item}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
        loading="lazy"
      />
    </ImageListItem>
  ))}
</ImageList>

再次运行应用程序并点击“加载 NFT 收藏”按钮。

铸造 NFT

添加 IService 接口。

interface IService {
  account: string;
  ethProvider?: ethers.providers.Web3Provider,
  contract?: NFTCollectible;
  currentBalance: number;
  ethBalance: string;
  mintAmount: number;
}

使用状态 hook。

const [service, setService] = React.useState<IService>({
    account: "",
    currentBalance: 0,
    ethBalance: "",
    mintAmount: 0
});

铸造 NFT 函数。

const mintNFTs = async () => {
    try {
      console.log("mint NFTs");
      const address = service.account;
      const amount = service.mintAmount!;
      const contract = service.contract!;
      const price = await contract.PRICE();
      const ethValue = price.mul(BigNumber.from(amount));
      const signer = service.ethProvider!.getSigner();
      let txn = await contract.connect(signer!).mintNFTs(amount, { value: ethValue });
      await txn.wait();
      const balance = await contract.balanceOf(address);
      setService({...service, currentBalance: balance.toNumber(), mintAmount: 0});
    } catch (error) {
      console.log(error);
    }
};

铸造模态对话框。

<Modal
    aria-labelledby="transition-modal-title"
    aria-describedby="transition-modal-description"
    open={open}
    onClose={handleClose}
    closeAfterTransition
    BackdropComponent={Backdrop}
    BackdropProps={{
      timeout: 500,
    }}
    >
    <Fade in={open}>
      <Box sx={modalStyle}>
        <Stack spacing={1} sx={{ width: 500 }}>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="mint_account" label="Account" 
            sx={{ width: 500 }} variant="standard" value={service.account}
              inputProps={{ readOnly: true}}
            />
          </Box>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="price" label="NFT Price" 
            sx={{ width: 500 }} variant="standard" value={state.contractPrice}
              inputProps={{ readOnly: true}}
            />
          </Box>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="balance" label="Balance" 
            sx={{ width: 500 }} variant="standard" value={service.currentBalance}
              type = "number" inputProps={{ readOnly: true}}
            />
          </Box>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="mint_amount" type="number" 
            label="Mint Amount" sx={{ width: 500 }} 
            variant="standard" value={service.mintAmount}
             onChange={event => {
              const { value } = event.target;
              const amount = parseInt(value); 
              setService({...service, mintAmount: amount});
            }}
             />
          </Box>
          <Stack direction="row" spacing={2} sx={{ margin: 5 }}>
            <Button variant="outlined" onClick={mintNFTs}>Mint</Button>
            <Button variant="outlined" onClick={handleClose}>close</Button>
          </Stack>
        </Stack>
      </Box>
    </Fade>
</Modal>

运行应用程序,点击“铸造 NFT”按钮。您将获得一个弹出对话框。

提现

Withdraw 函数。

const withdraw = async () => {
    try {
      console.log("owner withdraw");
      const contract = service.contract!;
      const provider = service.ethProvider!;
      let txn = await contract.withdraw();
      await txn.wait();
      const ethBalance = ethers.utils.formatEther
            (await provider!.getBalance(service.account));
      setService({...service, ethBalance: `${ethBalance} ETH`});
    } catch (error) {
      console.log(error);
    }
};

提现模态对话框。

<Modal
    id="withdrawal_modal"
    aria-labelledby="transition-modal-title"
    aria-describedby="transition-modal-description"
    open={openWithdrawal}
    onClose={handleCloseWithdrawal}
    closeAfterTransition
    BackdropComponent={Backdrop}
    BackdropProps={{
      timeout: 500,
    }}
    >
    <Fade in={openWithdrawal}>
      <Box sx={modalStyle}>
        <Stack spacing={1} sx={{ width: 500 }}>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="owner_account" label="Owner Account" 
            sx={{ width: 500 }} variant="standard" value={service.account}
              inputProps={{ readOnly: true }}
            />
          </Box>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="ethbalance" label="ETH Balance" 
            sx={{ width: 500 }} variant="standard" value={service.ethBalance}
              inputProps={{ readOnly: true }}
            />
          </Box>
          <Stack direction="row" spacing={2} sx={{ margin: 5 }}>
            <Button variant="outlined" onClick={withdraw}>Withdraw</Button>
            <Button variant="outlined" onClick={handleCloseWithdrawal}>close</Button>
          </Stack>
        </Stack>
      </Box>
    </Fade>
</Modal>

Withdraw 仅对部署合约的合约所有者可用。因此,Withdraw 按钮仅对所有者启用。

<Button variant="contained" disabled={!state.isOwner} 
onClick={handleOpenWithdrawal}>Withdraw</Button>

如果 MetaMask 当前连接的账户不是合约的所有者,则Withdraw 按钮将禁用。

MetaMask 中将账户更改为所有者。

更改为所有者后,点击我们 React 客户端中的连接按钮,Withdraw 按钮将启用。

点击“WITHDRAW”按钮。

就是这样。一个小而有趣的 Web3 应用就完成了。现在您打开了一扇通往全新世界的大门。

尾声:React Router

我们已经完成了 React Web3 客户端的构建。还有一个非常重要的 React 概念我还没有提到。那就是 React Router。在我们的应用程序中,我创建了一个 demo 组件,并直接将其放入 index.tsx。对于简单的应用程序来说,这不成问题。但是,如果您有多个组件需要导航怎么办?React Router 提供了一个完美的解决方案。

React Router 不仅仅是将 URL 匹配到函数或组件:它还可以构建一个与 URL 映射的完整用户界面,因此它可能包含比您习惯更多的概念。React Router 完成以下三个主要任务:

  • 订阅和操作历史堆栈
  • 将 URL 与您的路由匹配
  • 从路由匹配渲染嵌套 UI

安装 React Router。最新版本是 V6

npm install react-router-dom

现在在 App.tsx 中导入 react router 和 demo 组件。

import { BrowserRouter as Router, Routes, Route } from "react-router-dom";

import Link from '@mui/material/Link';

import Demo from './Demo'

App.tsx 中创建 Home 函数。使用 Link 让用户更改 URL 或自己使用 useNavigate。这里我们使用 Material UILink 而不是 react-router-dom 的。本质上它们是相同的,只是样式不同。

function Home() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
            With cryptocurrencies and blockchain technology, 
            NFTs have become part of this crazy world.            
        </p>
        <Box
          sx={{
            display: 'flex', flexWrap: 'wrap', 
                      justifyContent: 'center', typography: 'h3',
            '& > :not(style) + :not(style)': {
              ml: 2,
            },
          }}>
          <Link href="/demo" underline="none" sx={{ color: '#FFF' }}>
            Web3 NFT Demo
          </Link>
        </Box>
      </header>
    </div>
  );
}

App 函数中配置路由。

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/demo" element={<Demo />} />
      </Routes>
    </Router>
  );
}

在以前的 React Router 版本中,您必须以某种方式排序路由,以便当多个路由匹配含糊的 URL 时,正确的路由能够渲染。V6 更加智能,会选择最具体的匹配,因此您不再需要担心这一点。

别忘了最后一件事,在 index.tsx 中改回 App 组件。

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

现在运行我们的应用程序。

npm start

点击 Wbe3 NFT Demo,它将导航到我们的 Web3 组件。

如何使用源代码

首先,在您的浏览器(Chrome、Firefox 或 Edge)上安装 MetaMask 扩展。

然后下载并解压源代码,用 Visual Studio Code 打开 react-web3-ts 文件夹。

然后打开 VS Code 中的 New Terminal。

  • 安装所有依赖项
    npm install
  • 编译智能合约
    npx hardhat compile
  • 启动 hardhat 节点
    npx hardhat node
  • 打开另一个终端,部署合约
    npx hardhat run scripts/deploy.ts --network localhost
  • 启动应用程序
    npm start

结论

我们在这里涵盖了很多内容,从智能合约到 Web3 应用。我希望您学到了很多。现在您可能会问:“智能合约有什么用?”

智能合约相对于中心化系统的优势

  1. 数据无法被更改或篡改。因此,恶意行为者几乎不可能操纵数据。
  2. 它是完全去中心化的。
  3. 与任何中心化支付钱包不同,您无需向中间人支付任何佣金百分比即可进行交易。
  4. 最重要的是,智能合约可能会为您打开一扇通往财务自由的大门。

示例项目现在在 github 上,dapp-ts。祝您编码愉快!

历史

  • 2022 年 8 月 5 日:初始版本
© . All rights reserved.