Web3 全栈开发实战:ERC-20 代币集成与转账

在之前的文章中,我们完成了钱包连接、合约交互以及事件监听的完整闭环。今天,系列第三篇将聚焦 ERC-20 代币 —— 这是 DeFi 世界里最基础的资产标准。我们将从部署自己的测试代币开始,到前端查询余额、转账、授权,再到后端索引所有转账记录,一气呵成。

学完这篇文章,你将掌握:

  • 用 OpenZeppelin 部署 ERC-20 合约
  • 在前端查询代币信息(符号、精度、余额)
  • 实现代币转账与授权转账(Approve + TransferFrom)
  • 实时监听 Transfer 事件并同步 UI
  • 后端持久化转账记录并开放 API

所有代码依然基于 React + wagmi + viem + Node.js。


1. 部署一个测试代币

你需要一个自己的 ERC-20 代币来练习。打开 Remix IDE,创建 MyToken.sol

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

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

contract MyToken is ERC20 {
    constructor() ERC20("MyToken", "MTK") {
        _mint(msg.sender, 1000000 * 10 ** decimals());
    }
}
  • 编译后,在 “Deploy & run transactions” 中选择 Injected Provider - MetaMask,网络切换到 Sepolia
  • 点击 Deploy,确认交易。
  • 部署成功后,复制合约地址,例如 0xA1B2C3...,后面会频繁用到。

2. 前端:读取代币信息与余额

2.1 共享代币地址

新建 frontend/src/contracts/token.ts

export const TOKEN_ADDRESS = '0xA1B2C3...' // 替换为你的合约地址

2.2 使用 erc20Abi

viem 内置了完整的 ERC-20 ABI,我们直接导入:

import { erc20Abi } from 'viem'
import { useReadContract, useAccount } from 'wagmi'
import { TOKEN_ADDRESS } from './contracts/token'
import { formatUnits } from 'viem'

function TokenInfo() {
  const { address } = useAccount()

  const { data: symbol } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: erc20Abi,
    functionName: 'symbol',
  })

  const { data: decimals } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: erc20Abi,
    functionName: 'decimals',
  })

  const { data: balance } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: erc20Abi,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
  })

  const formattedBalance = balance !== undefined && decimals !== undefined
    ? formatUnits(balance, decimals)
    : '0'

  return (
    <div>
      <p>代币:{symbol?.toString()}</p>
      <p>精度:{decimals?.toString()}</p>
      <p>余额:{formattedBalance}</p>
    </div>
  )
}

记得在 App.tsx 中引入 <TokenInfo />


3. 前端:实现代币转账

转账就是调用 transfer(to, amount)。我们需要一个输入框和发送按钮:

import { useState } from 'react'
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { erc20Abi, parseUnits } from 'viem'
import { TOKEN_ADDRESS } from './contracts/token'

function TransferToken() {
  const [to, setTo] = useState('')
  const [amount, setAmount] = useState('')
  const { data: hash, writeContract } = useWriteContract()
  const { isLoading: isConfirming, isSuccess: isConfirmed } =
    useWaitForTransactionReceipt({ hash })

  const handleTransfer = () => {
    if (!to || !amount) return
    writeContract({
      address: TOKEN_ADDRESS,
      abi: erc20Abi,
      functionName: 'transfer',
      args: [to as `0x${string}`, parseUnits(amount, 18)], // 假设 18 精度
    })
  }

  return (
    <div>
      <h4>转账 MTK</h4>
      <input placeholder="接收地址" value={to} onChange={(e) => setTo(e.target.value)} />
      <input placeholder="数量" value={amount} onChange={(e) => setAmount(e.target.value)} />
      <button onClick={handleTransfer} disabled={isConfirming}>
        {isConfirming ? '确认中...' : '发送'}
      </button>
      {hash && <p>交易哈希:{hash}</p>}
      {isConfirmed && <p style={{ color: 'green' }}> 转账成功!</p>}
    </div>
  )
}

注意:真实项目中精度可以通过 useReadContract 获取,这里简化为 18。


4. 前端:授权与授权转账

有时 DApp 需要代表用户转移代币(例如去中心化交易所)。这要求用户先调用 approve(spender, amount),然后合约再调用 transferFrom。我们模拟一次授权给自己的另一个账户(或一个测试合约)。

function ApproveAndTransferFrom() {
  const [spender, setSpender] = useState('')
  const [approveAmount, setApproveAmount] = useState('')
  const [transferTo, setTransferTo] = useState('')
  const [transferAmount, setTransferAmount] = useState('')

  const { data: approveHash, writeContract: approve } = useWriteContract()
  const { data: transferFromHash, writeContract: transferFrom } = useWriteContract()

  const handleApprove = () => {
    approve({
      address: TOKEN_ADDRESS,
      abi: erc20Abi,
      functionName: 'approve',
      args: [spender as `0x${string}`, parseUnits(approveAmount, 18)],
    })
  }

  const handleTransferFrom = () => {
    // 注意:这里调用者必须是 spender,且需要用户已经授权
    transferFrom({
      address: TOKEN_ADDRESS,
      abi: erc20Abi,
      functionName: 'transferFrom',
      args: [
        '0xUserAddress', // from (代币持有者)
        transferTo as `0x${string}`,
        parseUnits(transferAmount, 18),
      ],
    })
  }

  return (
    <div>
      <h4>授权(Approve)</h4>
      <input placeholder="被授权地址 (spender)" value={spender} onChange={(e) => setSpender(e.target.value)} />
      <input placeholder="授权数量" value={approveAmount} onChange={(e) => setApproveAmount(e.target.value)} />
      <button onClick={handleApprove}>授权</button>
      {approveHash && <p>授权交易:{approveHash}</p>}

      <h4>授权转账(TransferFrom)</h4>
      <input placeholder="接收地址" value={transferTo} onChange={(e) => setTransferTo(e.target.value)} />
      <input placeholder="数量" value={transferAmount} onChange={(e) => setTransferAmount(e.target.value)} />
      <button onClick={handleTransferFrom}>授权转账</button>
      {transferFromHash && <p>转账交易:{transferFromHash}</p>}
    </div>
  )
}

实际使用时,from 地址应自动取当前连接的钱包地址,并且你可能需要先调用 approve 后再 transferFrom。这里展示两个独立按钮方便演示。


5. 前端:实时监听 Transfer 事件

代币转账和授权都会触发 Transfer 事件。我们用它来更新 UI,无需手动刷新余额。

import { useWatchContractEvent } from 'wagmi'
import { parseAbiItem } from 'viem'
import { TOKEN_ADDRESS } from './contracts/token'
import { erc20Abi } from 'viem'

function TransferWatcher() {
  useWatchContractEvent({
    address: TOKEN_ADDRESS,
    abi: erc20Abi,
    eventName: 'Transfer',
    onLogs(logs) {
      for (const log of logs) {
        const { from, to, value } = log.args
        console.log(`转账:${from} -> ${to},金额:${value}`)
        // 这里可以更新全局状态、弹窗通知等
      }
    },
  })

  return null // 只做监听,不渲染
}

把它放到 App 中,转账后立即就能看到控制台输出。你还可以把转账记录存入状态并渲染到页面,这和上一篇 ValueChanged 类似,不再赘述。


6. 后端:索引所有 Transfer 事件

前端监听的是实时数据,历史记录需要后端保存。

6.1 数据库准备

沿用之前的 SQLite 方案,新建表:

// backend/src/db.ts
db.exec(`
  CREATE TABLE IF NOT EXISTS transfer_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    blockNumber INTEGER NOT NULL,
    transactionHash TEXT NOT NULL,
    fromAddress TEXT NOT NULL,
    toAddress TEXT NOT NULL,
    value TEXT NOT NULL,
    timestamp INTEGER NOT NULL
  )
`)

export function insertTransferEvent(...) // 类似上篇
export function getTransfers(limit = 50) { ... }

6.2 事件监听器

使用 viemwatchContractEvent,监听 Transfer

import { createPublicClient, http, erc20Abi } from 'viem'
import { sepolia } from 'viem/chains'

const client = createPublicClient({ chain: sepolia, transport: http() })
const TOKEN_ADDRESS = '0xA1B2C3...'

export function startTransferListener() {
  client.watchContractEvent({
    address: TOKEN_ADDRESS,
    abi: erc20Abi,
    eventName: 'Transfer',
    onLogs(logs) {
      for (const log of logs) {
        const { from, to, value } = log.args
        if (!from || !to) continue
        insertTransferEvent(
          log.blockNumber,
          log.transactionHash,
          from,
          to,
          value.toString(),
          Math.floor(Date.now() / 1000)
        )
      }
    },
  })
}

6.3 提供转账历史 API

backend/src/index.ts 中加入:

app.get('/transfers', (req, res) => {
  const transfers = getTransfers(100)
  res.json(transfers)
})

// 启动监听器
startTransferListener()

为了保护 API,你可以像第一篇那样加上 JWT 认证中间件。


7. 前端集成历史接口

创建 TransferHistory.tsx

import { useEffect, useState } from 'react'

interface TransferRecord {
  fromAddress: string
  toAddress: string
  value: string
  transactionHash: string
}

export default function TransferHistory() {
  const [list, setList] = useState<TransferRecord[]>([])

  useEffect(() => {
    fetch('http://localhost:3001/transfers')
      .then(res => res.json())
      .then(setList)
  }, [])

  return (
    <div>
      <h3> 转账记录</h3>
      <ul>
        {list.map((t, i) => (
          <li key={i}>
            {t.fromAddress.slice(0, 6)}... → {t.toAddress.slice(0, 6)}... | 金额:{t.value}
          </li>
        ))}
      </ul>
    </div>
  )
}

在 App 中使用 <TransferHistory />


8. 运行与测试

  1. 确保后端已启动:cd backend && npx tsx src/index.ts
  2. 前端:cd frontend && pnpm dev
  3. 用 MetaMask 连接,确认你在 Sepolia 上。
  4. 你可以向自己另一地址转账少量 MTK,观察前端实时输出和后端数据库的更新。
  5. 刷新页面,历史记录依然存在。

9. 延伸思考

  • 精度处理:建议读取 decimals() 后动态格式化,不要硬编码 18。
  • 批量查询:对于多个代币或大量数据,可使用 useReadContracts 或后端分页。
  • 授权风险:生产环境中避免无限授权(approve max),UI 中应提示用户设置合理额度。
  • 索引效率:当事件海量增长时,可考虑使用 The Graph 或自建专用索引器。

10. 总结

通过本教程,你成功地在全栈 DApp 中集成了 ERC-20 代币:

  • 部署自己的测试代币
  • useReadContract 查询余额、符号
  • 使用 writeContract 实现转账与授权
  • 监听 Transfer 事件实现实时通知
  • 后端索引事件并提供查询 API