在之前的文章中,我们完成了钱包连接、合约交互以及事件监听的完整闭环。今天,系列第三篇将聚焦 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 事件监听器
使用 viem 的 watchContractEvent,监听 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. 运行与测试
- 确保后端已启动:
cd backend && npx tsx src/index.ts - 前端:
cd frontend && pnpm dev - 用 MetaMask 连接,确认你在 Sepolia 上。
- 你可以向自己另一地址转账少量 MTK,观察前端实时输出和后端数据库的更新。
- 刷新页面,历史记录依然存在。
9. 延伸思考
- 精度处理:建议读取
decimals()后动态格式化,不要硬编码 18。 - 批量查询:对于多个代币或大量数据,可使用
useReadContracts或后端分页。 - 授权风险:生产环境中避免无限授权(approve max),UI 中应提示用户设置合理额度。
- 索引效率:当事件海量增长时,可考虑使用 The Graph 或自建专用索引器。
10. 总结
通过本教程,你成功地在全栈 DApp 中集成了 ERC-20 代币:
- 部署自己的测试代币
- 用
useReadContract查询余额、符号 - 使用
writeContract实现转账与授权 - 监听
Transfer事件实现实时通知 - 后端索引事件并提供查询 API