Cover photo

你的第一个Web3 DApp

现在网络上已经有很多Web3应用教程的资源,可是作为新手往往还是很容易一头雾水。因为现有的技术栈实在太多了,同时大部分教程会默认读者对教程中所使用的技术很熟悉。于是即使是获取余额这样的简单功能,不同教程会给出完全不同的实现方法。实在是很不友好。

我作为一个只会一点c++/python的初学者,之前基本没有接触过前端代码,妥妥的初学者。整理本文一方面希望能给别的初学者些微帮助,同时也算是记录一下,免得自己过段时间忘记了如何操作。所以本文尽量不涉及DApp技术栈的底层细节,而是着眼于先写出一个可用的东西。想要在这个领域继续深入的读者,自然会再去自行寻找更多的技术知识来提高。

本文部分例子来自于《Web3 Tutorial: build DApp with Web3-React and SWR》

特别注意:因为本人完全不掌握前端技能,所以代码中的页面展示部分存在诸多不合理与需优化的地方。但这些都不是重点,本文的目标就是先让程序跑起来。

本文全部代码(不包含各种依赖库)已上传至Github

https://github.com/IrStar/hello-web3

  • 包管理:npm

  • IDE:VS Code

  • 前端页面:Next.js + TypeScript

  • UI库:NextUI

  • Provider: web3-react

  • wallet: MetaMask

  • 合约环境: hardhat

1. 搭建前端页面

建立文件夹/hello-web3作为我们的项目目录。通常来说,我们需要开发两部分内容:前端页面、链上合约。有些情况下可能还需要服务端,在本文先不涉及。为了方便,我们在本文会使用两个不同目录来保存相应内容。

我们使用Next.js + NextUI + TypeScript来进行前端页面开发。由于typescript是javascript的超集,web3-react也是基于typescript来编写,所以为了便于 照抄 学习其代码,我们也选择使用typescript。

cd ./hello-web3
npx create-next-app webapp --typescript
...

cd webapp

这时会自动创建一个名为/webapp的文件夹,我们会在其中存放前端部分的代码。为了看起来比较整洁,我们使用src作为root路径。(Next.js关于src文件夹的说明

mkdir src
mv pages src/pages
mv styles src/styles

这里还需要一步操作,打开tsconfig.json文件,

// 在"compilerOptions"中添加:
    "baseUrl": "./src",

然后运行 npm run dev,浏览器会自动打开一个窗口,也可以自己打开访问http://localhost:3000 显示Next.js的欢迎页面。

Next.js的欢迎页面
Next.js的欢迎页面

我们使用NextUI作为我们的UI库。

npm i @nextui-org/react

/src目录下新建/components目录,并在其中新建文件layout.tsx

import Head from 'next/head'
import React, { ReactNode } from 'react'
import styles from 'styles/layout.module.css'

type Props = {
  children: ReactNode
}

export default function Layout(props: Props) {
  return (
    <>
      <Head>
        <title>Hello Web3 World.</title>
      </Head>
      <main className={styles.main}>{props.children}</main>
    </>
  )
}

相应地,在/src/styles目录下新建文件layout.module.css,内容非常简单

.main {
  height: calc(100vh - 64px);
  background-color: white;
  padding: 0rem 8rem;
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

接下来修改/src/pages/_app.tsx

import type { AppProps } from 'next/app'
import Layout from 'components/layout'

export default function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
}

最后回来修改/src/pages/index.tsx

import type { NextPage } from 'next'
import { Grid, Card, Text, Button} from "@nextui-org/react";

interface Props {
  title: string,
  content: string
}

const HomeCard = (props:Props) => {
  return (
    <>
      <Card hoverable bordered shadow={false} css={{my: "10px"}}>
        <Text h4> {props.title} </Text>
        <Card.Footer>
          <Text> {props.content} </Text>
        </Card.Footer>
      </Card>
    </>
  )
}

const Home: NextPage = () => {

  return (
    <>
      <Grid.Container justify="flex-start">
        <Text h2 css={{my: "1rem", px: "5px"}}>
          第一个 Web3 DApp
        </Text>
        <HomeCard title='本文目标' content='让DApp跑起来' />
      </Grid.Container>
    </>
  )
}

export default Home

刷新浏览器窗口,可以看到新的页面。

简单的前端页面,我们将在此基础上添加更多内容
简单的前端页面,我们将在此基础上添加更多内容

2. 前端页面与MetaMask连接

目前web3-react有stable V6beta V8两个版本,我们在这里使用V6版本。回到/webapp文件夹,使用npm安装web3-react

npm i @web3-react/core @web3-react/injected-connector ethers

接下来修改/src/pages/_app.tsx,首先增加两个import

import { Web3ReactProvider } from '@web3-react/core'
import { Web3Provider } from '@ethersproject/providers'

增加getLibrary函数

function getLibrary(provider: any): Web3Provider {
  return new Web3Provider(provider)
}

然后对MyApp函数进行修改

export default function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Web3ReactProvider getLibrary={getLibrary}>
    <Layout>
      <Component {...pageProps} />
    </Layout>
    </Web3ReactProvider>
  )
}

接下来我们以连接MetaMask为例,在/src/components目录中新建一个connectMetaMask.tsx文件。如果要连接其它的钱包,使用web3-react提供的useWeb3React也可以很方便地实现,具体方法可以参考这篇文章

import { useEffect } from 'react'
import { Grid, Card, Button, Text} from '@nextui-org/react'
import { useWeb3React } from '@web3-react/core'
import { Web3Provider } from '@ethersproject/providers'
import { UserRejectedRequestError } from '@web3-react/injected-connector'
import { injectedConnector } from 'utils/connectors'
import { formatAddress } from 'utils/helpers'

const ConnectMetaMask = () => {

    const { account, chainId, active, library, connector, 
      activate, deactivate, setError } = useWeb3React<Web3Provider>()

    const onClickConnect = () => {
      activate(injectedConnector,(error) => {
        if (error instanceof UserRejectedRequestError) {
          // ignore user rejected error
          console.log("user refused")
        } else {
          setError(error)
        }
      }, false)
    }

    const onClickDisconnect = () => {
        deactivate()
      }

    useEffect(() => {
      console.log(chainId, account, active, library, connector)
    })

    return (
      <>
        <Card bordered shadow={false} css={{my: "10px"}}>
          {active && typeof account === 'string' 
            ? <Grid><Button onPress={onClickDisconnect} >
                Account: {formatAddress(account,4)}
                </Button>
                <Text>ChainID: {chainId} connected</Text>
              </Grid>
            : <Grid><Button onPress={onClickConnect}>
                Connect MetaMask
                </Button>
              <Text> not connected </Text>
              </Grid>
          }
        </Card>
      </>
    )
}

export default ConnectMetaMask

注意:本文使用的是web-react的V6版本。当使用V8版本时,const { library } = useWeb3React()语句中的library需要修改为provider

这里用到了两个功能函数。在/src目录下新建utils文件夹,并新建connectors.tsxhelpers.tsx两个文件。其中connector.tsx列出了当前支持的链ID,也可以再手动添加新的链ID

这里要注意的是,我们稍后会使用hardhat来进行合约开发和本地链的部署,所使用的链ID是31337,而在MetaMask中默认的本地链ID是1337,所以一定要记得修改,不然会导致连接失败。

import { InjectedConnector } from "@web3-react/injected-connector";

export const injectedConnector = new InjectedConnector({
    supportedChainIds: [
        1,      // mainnet
        3,      // ropsten
        4,      // rinkeby
        5,      // goerli
        42,     // kovan
        31337,  // localhost
    ]
})

helpers中提供了将40位地址只显示前后各4位的方法。

export function formatAddress(value: string, length: number = 4) {
    return `${value.substring(0, length + 2)}...${value.substring(value.length - length)}`
}

记得将我们刚刚新建的connectMetaMask引入到index.tsx中,然后刷新页面。

import ConnectMetaMask from 'components/connectMetaMask'
...
        <ConnectMetaMask />

可以看到我们的页面已经具备了与MetaMask连接的能力,并显示当前的链。

未连接状态
未连接状态
连接了以太坊主链
连接了以太坊主链

作为一个入门教程,我们只在本地链上操作,先不去访问真实链上的数据,所以就不需要Relay Network了。未来真实上链的时候,可以考虑Infura或者Alchemy。我个人使用Alchemy,主要是因为个人感觉它的免费方案更实惠。还有Moralis提供了直接访问区块链状态的功能,避免反复调用第三方网络的API,不过我对这个功能的需求不是很强,所以暂时还没有尝试。

3. 合约编写

我们首先回到/hello-web3,创建/chain文件夹,并进行初始化。我们将把合约相关的代码放在这个目录里。

mkdir chain
cd chain
npm init -y

接下来我们需要安装hardhat作为本地的Solidity合约开发环境,并创建起始项目。

npm i hardhat

创建起始项目

这里会出现几个选项。使用箭头上下移动,选择Create an advanced sample project that uses TypeScript 其实选择另外几个选项也是可以的,不过我懒得手动安装工具包,就让hardhat自己看着来了

一路回车。hardhat会自动安装一些工具包,等待安装完成后即可。hardhat会自动生成一个样例合约,一个部署脚本和一个测试脚本。有兴趣的同学可以试试。我们先把它们删除,再利用OpenZeppelin编写一个简单的ERC20合约,那么先安装OpenZeppelin

npm i @openzeppelin/contracts

接下来,在/chain/contracts目录中新建HelloToken.sol

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract HelloToken is ERC20 {
    constructor(uint supply) ERC20("HelloToken", "HLT") {
        _mint(msg.sender, supply);
    }
}

该合约定义了一种叫做HelloToken的ERC20代币,简写符号为HLT,当有人在链上部署该合约时,会给部署者发放若干数量的代币。

我们快速写一个单元测试脚本/chain/test/HelloToken.test.ts

import { expect } from "chai";
import { ethers } from "hardhat";

describe("HelloToken Initial Supply", function () {
  it("Should have the correct initial supply", async function () {
    const initialSupply = ethers.utils.parseEther('10000.0')
    const HelloToken = await ethers.getContractFactory("HelloToken");
    const token = await HelloToken.deploy(initialSupply);
    await token.deployed();

    expect(await token.totalSupply()).to.equal(initialSupply);
  });
});

再写一个简单的部署脚本/chain/scripts/HelloToken_deploy.ts

import { ethers } from "hardhat";

async function main() {
  const initialSupply = ethers.utils.parseEther('10000.0')
  const HelloToken = await ethers.getContractFactory("HelloToken");
  const token = await HelloToken.deploy(initialSupply);
  await token.deployed();
  console.log("HelloToken deployed to:", token.address);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

这里需要注意一点,hardhat的本地测试链有in-process和stand-alone两种模式。当没有指定网络时,默认使用in-process模式。但是由于in-process模式并不具有独立的链ID,所以大部分情况下我们需要使用的是stand-alone模式。为了启动stand-alone模式的本地测试链,首先打开一个新的终端窗口并导航到当前的/chain目录中,然后运行

稍等一下,就能看到本地测试链启动成功,并创建了20个账号地址与它们的私钥,每个地址上有10000个ETH。

共创建20个账号地址与相应私钥。注意由于这些地址的私钥是公开的,所以任何人都可以操作主链上的这些地址。千万不要在主链上给这些地址转账!有兴趣的同学可以去Etherscan上面看看主链上的这几个地址里面的存款和交易记录,被坑过的人不少啊!
共创建20个账号地址与相应私钥。注意由于这些地址的私钥是公开的,所以任何人都可以操作主链上的这些地址。千万不要在主链上给这些地址转账!有兴趣的同学可以去Etherscan上面看看主链上的这几个地址里面的存款和交易记录,被坑过的人不少啊!

回到之前的终端窗口中,来运行单元测试。

npx hardhat test --network localhost

注意,如果只输入npx hardhat test而忽略掉后面的--network localhost,则使用的就是in-process模式。

那么继续,在本地测试链上部署我们的ERC20合约。

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

部署成功后,通过部署脚本打印出了合约的部署地址。

这里显示的就是合约在本地测试链上的部署地址
这里显示的就是合约在本地测试链上的部署地址

一定要注意的是,每次重新部署都可能让合约地址发生变化,所以在与合约交互时一定要注意传递的合约地址是否正确。有兴趣的同学也可以尝试通过hardhat提供的控制台来与合约进行交互,这里就省略了。

当我们部署合约时,可以看到前面执行了npx hardhat node的控制台窗口中一直有信息输出,包括在链上执行的只读指令和出块信息。持续一段时间之后,可能会导致我们无法查看之前创建的账号地址与私钥。这时可以在另一个终端窗口中执行npx hardhat accounts,来查看当前测试链上的账号地址信息。

4. 通过页面读取合约

为了读取合约,我们需要让前端知道合约都提供了哪些可用方法。回到/hello-web3/webapp/src目录,新建/abi文件夹,并在其中新建HelloTokenABI.tsx文件。

export const HelloTokenABI = [
    // Read-Only Functions
    "function balanceOf(address owner) view returns (uint)",
    "function totalSupply() view returns (uint)",
    "function symbol() view returns (string)",
];

我们使用了合约中提供的三个只读方法。其中balanceOf返回指定地址中的代币数量,totalSupply返回代币的总发行数量,symbol返回代币的简写符号。

/src/components目录中新建HelloToken.tsx

import React, { useEffect,useState } from 'react'
import { Card, Text } from "@nextui-org/react"
import {HelloTokenABI as abi} from 'abi/HelloTokenABI'
import { useWeb3React } from '@web3-react/core'
import { Web3Provider } from '@ethersproject/providers'
import {Contract} from "@ethersproject/contracts";
import { formatEther } from "@ethersproject/units"
import { parseEther }from "@ethersproject/units"

interface Props {
    addressContract: string
}

export default function HelloToken(props:Props){
  const addressContract = props.addressContract
  const [symbol,setSymbol]= useState<string>("")
  const [totalSupply,setTotalSupply]=useState<string>()
  const [balance,setBalance]=useState<string|undefined>(undefined)

  const { account, active, library } = useWeb3React<Web3Provider>()

  useEffect( () => {
    if(!(active && account && library)) return

    const contract = new Contract(addressContract, abi, library);
    library.getCode(addressContract).then((result:string)=>{
      //check whether it is a contract
      if(result === '0x') return
      
      contract.symbol().then((result:string) => {
        setSymbol(result)
      }).catch('error', console.error)
  
      contract.totalSupply().then((result:string) => {
        setTotalSupply(formatEther(result))
      }).catch('error', console.error)
    })
  //called only when changed to active
  },[active])

  useEffect(()=>{
    if(!(active && account && library)) {
        setBalance(undefined)
        return
    }

    const contract = new Contract(addressContract, abi, library);
    library.getCode(addressContract).then((result:string)=>{
      //check whether it is a contract
      if(result === '0x') return

      contract.balanceOf(account).then((result:string) => {
        setBalance(parseFloat(formatEther(result)).toFixed(2))
      }).catch('error', console.error)
    })
  },[active,account])

 return (
    <>
      <Card hoverable bordered shadow={false} css={{my: "10px"}}>
        <Text><b>HelloToken合约地址</b>: {addressContract}</Text>
        <Text><b>Token总发行量</b>: {totalSupply} {symbol}</Text>
        <Text><b>当前钱包中的HelloToken数量</b>: {balance} {symbol}</Text>
      </Card>
    </>
  )
}

这里同样注意,当将web-react从V6版本更新到V8版本后,原有的const { library } = useWeb3React()需要将library修改为provider

相应还要修改/src/pages/index.tsx

import HelloToken from 'components/HelloToken';
...
    const addressContract = '0x5FbDB2315678afecb367f032d93F642f64180aa3'
...
        <HelloToken addressContract={addressContract} />

注意,这里我们赋给addressContract的值,就是前面所得到的合约地址。如果得到的合约地址变化了,这里也一定要修改成实际的地址。另外,需要将MetaMask的网络切换到 Localhost 8545也就是本地测试链。

连接钱包后,即可显示当前钱包地址中拥有的HelloToken代币数量
连接钱包后,即可显示当前钱包地址中拥有的HelloToken代币数量

5. 从页面向合约发送数据

刚才我们只使用了合约当中的只读方法,也就是只能读取链上数据,并不能对数据做出修改。那么现在我们来尝试对链上数据进行修改操作。

首先还是在/src/abi/HelloTokenABI.tsx文件中,增加合约所提供的写入链上状态数据的方法。这里准备使用合约中的transfer方法,该方法实现了转账功能,会将调用者地址中的若干数量的代币,发送至目标地址中。

"function transfer(address to, uint amount) returns (bool)",

然后我们修改/src/components/HelloToken.tsx文件。首先增加两个hooks,用于设置目标地址和待发送数量

const [toAddress, setToAddress]=useState<string>("")
const [amount,setAmount]=useState<string>('100')

然后增加一个异步函数用于完成实际的转账功能

async function onTransfer(event:React.FormEvent) {
    event.preventDefault()
    if(!(active && account && library)) return

    // new contract instance with **signer**
    const contract = new Contract(addressContract, abi, library.getSigner());
    contract.transfer(toAddress,parseEther(amount)).catch('error', console.error)
  }

注意在实例化contract时,与上一节中的方法相比,最后一个参数从library变成了library.getSigner(),这是因为该操作会改变链上状态数据,所以需要调用者进行签名。当然,还需要支付gas

然后增加需要在页面上显示的内容,相应也要记得引入NextUI库的Input和Button组件

      <Card hoverable bordered shadow={false} css={{my: "10px"}}>
        <form onSubmit={onTransfer}>
          <Input label="Amount" type="number" css={{mr: "10px"}}
            required onChange={(e) => setAmount(e.target.value)}/>
          <Input label="To Address" type="text" css={{ml: "10px"}}
            required onChange={(e) => setToAddress(e.target.value)} />
          <Button type="submit" disabled={!account} css={{mt: "20px"}}> Transfer </Button>
        </form>
      </Card>

刷新浏览器页面。首先连接钱包,然后填入准备转账的代币数量,这里以1000个为例,再从测试链的20个钱包地址中随便选一个地址作为目标地址。点击Transfer发送,MetaMask会弹出来要你签名确认,几秒钟后转账成功!

两点需要注意。第一点是发送代币数量不要多于当前钱包的拥有数量,否则合约执行会发生错误,而我们目前还没有处理链上发出的错误。第二点是不要使用当前钱包地址作为转账目标地址,否则合约执行同样会发生错误。

MetaMask提示的交易信息,确认无误后点击确认
MetaMask提示的交易信息,确认无误后点击确认
耐心等待几秒钟,就能看到交易已经成功了
耐心等待几秒钟,就能看到交易已经成功了

不过虽然转账成功了,但是页面上显示的当前账户金额却没有变化。这是因为我们的useEffect hook并没有被触发。我们接下来就解决这个问题。

6. 监听合约事件

由于链上的合约执行结束后并不会向链外返回数据,所以即使链上状态数据发生了变化,在链外也无法得知。为了解决这个问题,合约会在链上状态数据发生变化时发送事件广播。在链外对合约的事件广播进行监听,就可以确定链上发生了什么事情。例如对于ERC20合约,在transfer执行成功后会发送Transfer事件广播。我们首先修改/src/abi/HelloTokenABI.tsx文件,告知前端页面可能会接收到的事件类型。

"event Transfer(address indexed from, address indexed to, uint amount)",

然后修改/src/components/HelloToken.tsx文件,增加对Transfer事件的监听。我们之前有一个useEffect hook用于更新账户持币数量,现在需要在其中增加两个监听器,一个用于监听当前地址向外发送代币,另一个用于监听当前地址收到代币。

  useEffect(()=>{
    if(!(active && account && library)) {
        setBalance(undefined)
        return
    }

    const contract = new Contract(addressContract, abi, library);
    library.getCode(addressContract).then((result:string)=>{
      //check whether it is a contract
      if(result === '0x') return

      balanceOf(contract, account)

      // listen for changes on an Ethereum address
      console.log(`listening for Transfer...`)

      const fromMe = contract.filters.Transfer(account, null)
      contract.on(fromMe, (from, to, amount, event) => {
        console.log('Transfer|sent', { from, to, amount, event })
        balanceOf(contract, account)
      })

      const toMe = contract.filters.Transfer(null, account)
      contract.on(toMe, (from, to, amount, event) => {
        console.log('Transfer|received', { from, to, amount, event })
        balanceOf(contract, account)
      })

      // remove listener when the component is unmounted
      return () => {
        contract.removeAllListeners(fromMe)
        contract.removeAllListeners(toMe)
      }
    })
  },[active,account])

我们将查询当前地址代币余额的方法封装为了balanceOf方法。

  async function balanceOf(contract:Contract, account:any) {
    contract.balanceOf(account).then((result:string) => {
        setBalance(parseFloat(formatEther(result)).toFixed(2))
      }).catch('error', console.error)
  }

刷新浏览器页面,连接钱包,发送代币。耐心等待几秒钟,链上交易完成后,页面自动刷新成功。

我们再发送1000个代币,成功后页面自动刷新,显示当前钱包中只剩下了8000个HelloToken
我们再发送1000个代币,成功后页面自动刷新,显示当前钱包中只剩下了8000个HelloToken

大功告成。

当然,这个DApp还非常简单,缺少实际生产环境中必须具备的很多功能。比如当链上合约抛出revert错误时如何处理、不同钱包的切换等等。合约提供的功能也非常简单。现有代码还存在着大量的优化空间。不过这毕竟是一个开始。