系列导航
| 顺序 | 文章 | 定位 |
|---|---|---|
| 1 | 零代码,建立整体认识 | |
| 2 | 合约源码、ABI、测试、部署 | |
| 3 | 📍当前:前端:用 MetaMask + ethers 完成链上转账 | RPC、钱包、Provider/Signer、转账 12 步 |
| 4 | 事件订阅、确认数、幂等、可靠性 |
读完本篇你将学会
- 前端是怎样和区块链"说话"的:RPC、JSON-RPC、MetaMask、ethers 各自扮演什么角色。
- 用
new Contract(地址, ABI, runner)把链上合约变成一个普通的 JS 对象,并分清"读"和"写"。 - 一笔 MTK 转账在前端从"连接钱包"到"列表出现记录"的完整 12 个步骤。
- 如何把 Web3 特有的英文报错翻译成用户看得懂的提示。
- 用 30 行 HTML 独立完成一次链上转账。
前置知识
建议先读第 1 篇(整体流程)和第 2 篇(智能合约)。本篇默认你已经知道:
- 合约与 MTK 代币:部署在链上的程序,记录"谁有多少 MTK"。
- ABI:合约的"接口说明书",前端靠它知道怎么调用合约。
- 函数选择器:函数签名哈希的前 4 字节,例如
transfer是0xa9059cbb。 - Transfer 事件:合约转账成功时写下的日志,后端靠它同步数据。
- Gas:在链上执行"写"操作要付的手续费。
- 前端基础:
fetch、async/await,能看懂 Vue 3 / TypeScript 代码。
文中代码均摘自本项目
frontend/src/源码,请求与返回数据均为真实数据(Sepolia 测试网实测)。
目录
- 定位:前端在 Web3 中扮演什么角色
- 技术栈与目录结构
- 前端如何与链通信:RPC、MetaMask 与 ethers
- 用 ABI 调用合约:new Contract
- ★ 链上转账:前端的 12 个步骤
- 与后端交互:统一请求封装
- 错误处理:把英文报错翻译成人话
- 动手 Demo:30 行 HTML 完成一次链上转账
- 常见问题
- 下一篇预告
一、定位:前端在 Web3 中扮演什么角色
这一章先回答:Web3 前端到底负责什么,它和我们熟悉的 Web2 前端有什么不同。
1.1 一句话定位
前端是用户、钱包、区块链、后端之间的"调度员"。 它不保存钱,也不保存私钥;它把用户的操作翻译成钱包能签名的交易、链能执行的调用,再把结果展示出来。它要同时和钱包、区块链、后端三方打交道:
| 找谁 | 做什么 | 走什么通道 | 花钱吗 |
|---|---|---|---|
| 钱包 | 获取账户、切换网络、签名转账 | window.ethereum.request() | 签名本身不花钱 |
| 区块链 | 查余额(读) | 经钱包转发的 RPC:eth_call | 免费 |
| 区块链 | 转账(写) | 钱包签名后 eth_sendRawTransaction | 要付 Gas |
| 后端 | 昵称、交易列表、同步进度 | fetch('/api/...') | 免费 |
1.2 与 Web2 前端最大的不同
| 对比 | Web2 前端 | Web3 前端(本项目) |
|---|---|---|
| 登录 | 账号密码 → 后端发 token | 连接钱包:拿到地址就等于"登录",无需密码 |
| 转账 | 调后端接口,后端改数据库 | 前端直接把交易交给钱包签名,发到链上,后端完全不参与 |
| 数据来源 | 全部来自后端 | 余额来自链(权威);列表来自后端(副本) |
| 失败原因 | 接口报错 | 用户拒绝签名、Gas 不足、合约 revert、网络不对…… |
关键认知:转账这条主链路上没有后端。就算后端挂了,用户照样能转账,只是列表暂时不更新。
二、技术栈与目录结构
这一章快速认识项目用了什么、代码放在哪里,后面讲解时会反复引用这些文件。
| 技术 | 版本 | 作用 |
|---|---|---|
| Vue 3 + TypeScript | 3.5 / 6.0 | 页面框架(<script setup> 组合式 API) |
| TDesign Vue Next | 1.20 | UI 组件库(表单、表格、步骤条) |
| ethers | 6.17 | 与链交互的核心库:连接钱包、编码调用、解码结果 |
| Vite | 8.3 | 开发服务器与打包 |
frontend/src/
├── main.ts # 入口:注册 TDesign、引入全局样式
├── App.vue # 根组件:只挂载页面
├── views/home/index.vue # ★ 首页:把下面所有模块串成完整的转账流程
├── components/
│ ├── TxList/index.vue # 交易列表("我的"和"对方的"复用)
│ ├── WalletInfo/index.vue # "我的钱包"卡片
│ └── TxLifecycle/index.vue # 转账进度条:签名广播 → 打包上链 → 后端入库
├── hooks/
│ ├── useWallet.ts # 钱包状态:连接 / 断开 / 刷新后恢复 / 监听切换
│ └── useSyncStatus.ts # 轮询后端同步进度
├── api/ # 后端接口:user.ts / transaction.ts / network.ts
├── utils/
│ ├── http/index.ts # fetch 封装:统一处理 {code, msg, data}
│ ├── web3/ # ★ 与链交互
│ │ ├── wallet.ts # 钱包:账户授权、切换网络、Provider
│ │ ├── token.ts # 合约:balanceOf / transfer、单位换算
│ │ └── error.ts # 错误翻译
│ ├── format.ts # 地址缩写、时间格式化
│ └── storage.ts # localStorage 安全读写
├── settings/networkSetting.ts # anvil / sepolia 的链配置、合约地址
├── enums/ # cacheEnum.ts(缓存键)、txStageEnum.ts(转账阶段)
├── abi/ # ★ 由 contracts/export-abi.sh 自动生成,勿手改
│ ├── MyToken.abi.json # 合约接口说明书
│ └── deployments.json # 各链合约地址、部署区块、部署者
└── types/global.d.ts # window.ethereum 的类型声明
分层原则:utils/web3 只管"怎么和链说话",api 只管"怎么和后端说话",hooks 管"状态",views 负责把它们串成业务流程。
三、前端如何与链通信:RPC、MetaMask 与 ethers
这一章要解决的问题:浏览器里的一段 JS,怎样查到链上的余额、把交易发到链上?
浏览器不能直接连上以太坊网络里成千上万个节点,它需要一个入口,这个入口就是 RPC 节点;双方说话用的"语言"是 JSON-RPC 协议。前端又不直接找节点,而是经过 MetaMask 转发,并用 ethers 这个库来省去手写报文的麻烦。下面按"RPC → MetaMask → ethers"的顺序逐层讲。
3.1 RPC 与 JSON-RPC 是什么
RPC(Remote Procedure Call,远程过程调用):像调用本地函数一样,去调用另一台机器上的函数。你只管说"调用哪个函数、参数是什么",对方执行完把结果还给你。
JSON-RPC 是一种用 JSON 描述"调用哪个方法、传什么参数"的 RPC 格式。以太坊的每个节点(geth、anvil、Infura 背后的节点……)都提供一组标准化的 JSON-RPC 方法,前端常用的有:
| 方法 | 作用 | 读 / 写 | 前端哪里用到 |
|---|---|---|---|
eth_chainId | 当前是哪条链 | 读 | 校验钱包网络(getWalletChainId) |
eth_call | 模拟执行合约的读函数 | 读 | 查 MTK 余额(balanceOf) |
eth_estimateGas | 预估交易要花多少 Gas | 读 | ethers 发交易前自动调用 |
eth_sendRawTransaction | 广播一笔已签名的交易 | 写 | MetaMask 签名后调用 |
eth_getTransactionReceipt | 查交易回执(是否上链、成功与否) | 读 | tx.wait() |
eth_blockNumber | 最新区块号 | 读 | tx.wait() 判断确认 |
eth_getBalance | 查原生币(ETH)余额 | 读 | MetaMask 显示 SepoliaETH 余额 |
后端还会用到
eth_getLogs、eth_subscribe等方法来同步事件,见第 4 篇的「与节点通信:JSON-RPC over HTTP / WebSocket」一章。
报文格式非常固定:请求里写方法名和参数,响应里带回结果或错误。下面是对 Sepolia 节点发出的真实请求与响应:
# 查询最新区块号
curl -X POST https://sepolia.infura.io/v3/<API Key> \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'
{ "jsonrpc": "2.0", "id": 1, "result": "0xb3e92b" }
0xb3e92b 就是十进制的 11790635,即当时 Sepolia 的最新区块号。链上的数字一律用十六进制字符串传输。
| 字段 | 含义 |
|---|---|
jsonrpc | 协议版本,固定是 "2.0" |
id | 请求编号,响应会带回同一个 id,用来对应"哪个回答属于哪个问题" |
method | 要调用的方法名 |
params | 参数数组 |
result / error | 成功时返回 result,失败时返回 error |
调用一个不存在的方法,会返回标准错误码(真实响应):
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32601,
"message": "The method eth_foo does not exist/is not available"
}
}
JSON-RPC 还支持批量请求:一次发送一个数组,节点按 id 逐个回答(真实响应):
// 请求:同时查链 ID 和"部署者和持币人"的 SepoliaETH 余额
[{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]},
{"jsonrpc":"2.0","id":2,"method":"eth_getBalance","params":["0x83da6626ef0721da9d452a7eedc4f291a3f9d88c","latest"]}]
// 响应:0xaa36a7 = 11155111(Sepolia);0x1a04f6179bce57034 wei ≈ 29.998 SepoliaETH
[{"jsonrpc":"2.0","id":1,"result":"0xaa36a7"},
{"jsonrpc":"2.0","id":2,"result":"0x1a04f6179bce57034"}]
3.2 JSON-RPC、HTTP 与 REST 是什么关系
常见误解是"区块链用 RPC,不用 HTTP"。实际上两者不在同一个层面:
| 对比 | 是什么 | 类比 |
|---|---|---|
| HTTP / WebSocket | 传输层:数据怎么送过去 | 邮路:平邮、快递 |
| JSON-RPC | 消息格式:送过去的内容长什么样 | 信的格式:称呼、正文、落款 |
JSON-RPC 的请求就装在 HTTP 请求里:上面那条 curl 本身就是一个普通的 HTTP POST。同一份报文也可以通过 WebSocket 发送,后端订阅事件用的就是这种方式(HTTP 与 WebSocket 的详细对比见第 4 篇)。
所以真正值得比较的,是 JSON-RPC 和 REST 这两种接口风格。本项目里两种都有:
| 对比 | REST 风格(本项目的后端接口) | JSON-RPC 风格(区块链节点) |
|---|---|---|
| 例子 | GET /api/transactions?address=0x… | POST /,body 里写 {"method":"eth_call",...} |
| URL | 每种资源一个 URL | 只有一个 URL |
| HTTP 方法 | GET / POST / PUT / DELETE 各有含义 | 基本只用 POST |
| "要做什么"写在哪 | URL 路径 + HTTP 方法 | body 里的 method 字段 |
| 能否跑在 WebSocket 上 | 不适合 | 可以,报文格式完全一样 |
| 批量调用 | 需要自己设计 | 协议自带(发送数组) |
为什么以太坊选 JSON-RPC,而不是 REST?
- 操作本身就是"调用函数":
eth_call、eth_sendRawTransaction都是"执行某个动作",而不是"增删改查某个资源",用"方法名 + 参数"表达最自然。 - 与传输方式无关:同一套方法可以跑在 HTTP(一问一答)、WebSocket(能收到推送,后端的
eth_subscribe依赖它)、IPC(本机进程间通信)上。REST 绑定在 HTTP 上,做不到服务器主动推送。 - 全网统一标准:geth、anvil、Infura、Alchemy 都实现同一份规范,所以 ethers、MetaMask、forge 能对接任意节点,换节点只需换一个 URL。
- 批量请求:一次网络往返查多个数据,协议原生支持。
3.3 MetaMask:既转发 RPC,又负责签名
MetaMask 本身不是区块链节点。它为每个网络保存了一个 RPC 地址(在 MetaMask 的"设置 → 网络"里可以看到),然后扮演两个角色:RPC 转发者(把只读请求原样转发给节点)和签名者(需要私钥的操作先弹窗让用户确认,签名后再发给节点)。打个比方:MetaMask 像银行柜员,查询业务直接转给后台;取钱业务必须先让你本人签字。
页面怎么和 MetaMask 说话? 安装 MetaMask 后,它会往每个网页注入一个 window.ethereum 对象。它遵循 EIP-1193(以太坊改进提案 1193,规定了钱包对网页暴露的标准接口)。页面和钱包的所有交互都通过它的 request() 方法完成(类型声明见 src/types/global.d.ts)。request() 用的方法名就是 JSON-RPC 的方法名,再加上一些钱包专属的 wallet_* 方法:
// 在装了 MetaMask 的浏览器控制台里可以直接试
await window.ethereum.request({ method: "eth_chainId" }); // "0xaa36a7" → Sepolia
await window.ethereum.request({ method: "eth_blockNumber" }); // 转发给节点
await window.ethereum.request({ method: "eth_requestAccounts" }); // MetaMask 自己处理:弹窗授权
除了上面这些,本项目还用到 eth_accounts(获取已授权账户,不弹窗)、wallet_requestPermissions(重新申请权限,用于切换账户)、wallet_revokePermissions(撤销授权,即断开连接)、wallet_switchEthereumChain / wallet_addEthereumChain(切换 / 添加网络)。
MetaMask 收到请求后,按方法分成三类处理:
| 方法 | MetaMask 怎么处理 | 会弹窗吗 |
|---|---|---|
eth_call、eth_blockNumber、eth_getTransactionReceipt、eth_estimateGas …… | 直接转发给当前网络的 RPC 节点 | 否 |
eth_requestAccounts、wallet_switchEthereumChain、wallet_addEthereumChain …… | 自己处理(涉及账户授权、网络设置) | 是 |
eth_sendTransaction | 先弹窗 → 用户确认 → 用私钥本地签名 → 再调用节点的 eth_sendRawTransaction 广播 | 是 |
以一次转账为例,完整链路是这样的:
页面(ethers)
│ ① eth_estimateGas ───────────────────────────────▶ 转发给节点
│ ② eth_sendTransaction(未签名)
▼
MetaMask
│ ③ 弹窗:显示网络、合约地址、手续费
│ ④ 用户点"确认"
│ ⑤ 用私钥在本地签名(私钥从不离开 MetaMask)
│ ⑥ eth_sendRawTransaction(已签名)───────────────────────────▶ RPC 节点 → 全网广播
│ ⑦ 返回 txHash
▼
页面:tx.hash
│ ⑧ 反复 eth_getTransactionReceipt ──────────────────────────▶ 转发给节点,直到拿到回执
"添加网络"就是在告诉 MetaMask 用哪个 RPC。钱包里没有 anvil 网络时,本项目会调用 wallet_addEthereumChain,传入 chainId: '0x7a69'、rpcUrls: ['http://127.0.0.1:8545'](完整代码见第五章步骤 3)。之后 MetaMask 就知道:在"本地链"上,所有请求都发往本机的 anvil。这也解释了为什么 anvil 没启动时页面会报"链上没有返回数据":MetaMask 把请求转发到 8545 端口,那里却没有节点在监听。
3.4 ethers:Provider 与 Signer
有了 MetaMask,前端理论上已经能用 window.ethereum.request() 手写所有报文,但编码参数、解码结果、编排多个请求都很繁琐。ethers 就是来做这些脏活的 JS 库。它把"和链打交道"的对象分成两种(类比:Provider 是只能查账的访客,Signer 是拿着印章、能办转账的账户本人):
| 对比 | Provider(提供者) | Signer(签名者) |
|---|---|---|
| 能做什么 | 只读:查余额、查区块、查回执 | 读 + 写:还能签名并发送交易 |
| 需要私钥吗 | 不需要 | 需要(私钥在钱包里,由钱包代签) |
| 本项目中 | getProvider() | await getProvider().getSigner() |
// src/utils/web3/wallet.ts
/** 每次新建 Provider:切换网络后,旧 Provider 缓存的链信息会失效(ethers 会抛 network changed) */
export function getProvider(): BrowserProvider {
return new BrowserProvider(wallet());
}
ethers 的 Provider 就是一个 RPC 客户端:你调用它的方法,它负责拼 JSON-RPC 报文、发送请求、解析结果。按"请求发往哪里",常用的 Provider 有三种:
| Provider | 请求发往哪里 | 能签名吗 | 典型场景 |
|---|---|---|---|
BrowserProvider | window.ethereum(由 MetaMask 转发) | 能(由钱包签名) | 本项目前端 |
JsonRpcProvider | 直接发往一个 HTTP RPC 地址 | 不能(除非配私钥) | 只读页面、Node.js 脚本 |
WebSocketProvider | 直接发往一个 WebSocket RPC 地址 | 不能(除非配私钥) | 需要实时推送的场景 |
本项目为什么选 BrowserProvider?
- 转账必须由钱包签名,只能通过
window.ethereum。 - 读写走同一条通道:钱包切到哪条链,读到的数据就来自哪条链,不会出现"钱包在 Sepolia、读余额却读的是主网"这种错位。
- 不暴露 API Key:前端代码会被下载到每个用户的浏览器里,写在里面的 Infura Key 等于公开。借用 MetaMask 自己的节点,前端就不需要任何 Key。
ethers 常用方法与背后 JSON-RPC 方法的对应关系:
| ethers 代码 | 背后发出的 JSON-RPC 请求 |
|---|---|
provider.getNetwork() | eth_chainId |
provider.getBlockNumber() | eth_blockNumber |
provider.getBalance(addr) | eth_getBalance(原生币余额) |
contract.balanceOf(addr)(读函数) | eth_call,data = 选择器 0x70a08231 + 参数 |
contract.transfer(to, amount)(写函数,runner 是 Signer) | eth_estimateGas → eth_sendTransaction(交给 MetaMask 签名,MetaMask 再发 eth_sendRawTransaction) |
tx.wait() | 轮询 eth_getTransactionReceipt,直到拿到回执 |
provider.getLogs(filter) | eth_getLogs |
可见 ethers 帮你做了两件事:一是把函数调用翻译成 JSON-RPC 报文(根据 ABI 编码 calldata,把十六进制结果解码成 bigint);二是把多个 RPC 调用编排成完整流程(一句 contract.transfer() 背后是估算 Gas、请求签名、广播交易好几步)。
3.5 小结:RPC 在项目中的位置
前端用到的节点有三个,前端代码里没有写任何 Infura 地址:
| 节点 | 地址 | 前端怎么用到它 |
|---|---|---|
| MetaMask 内置的 Sepolia 节点 | 由 MetaMask 自己配置 | 页面选 Sepolia 时,所有请求经 MetaMask 转发到这里 |
| anvil(本地链) | http://127.0.0.1:8545 | 通过 wallet_addEthereumChain 告诉 MetaMask 这个地址,之后经 MetaMask 转发 |
| publicnode(公共节点) | https://ethereum-sepolia-rpc.publicnode.com | 只在"向 MetaMask 添加 Sepolia 网络"时作为参数传入 |
需要 API Key 的 Infura 节点只由后端访问(后端用到的节点和计费细节见第 4 篇)。项目里共有三条通信线路:前端 ↔ 链走 JSON-RPC,经 MetaMask 转发,读写同一条通道;后端 ↔ 链走 JSON-RPC,直连 Infura / anvil,HTTP 用于查询,WebSocket 用于订阅推送;前端 ↔ 后端是 REST 风格的普通 HTTP 接口,和区块链无关。
四、用 ABI 调用合约:new Contract
上一章解决了"怎么把请求送到链上",这一章解决"请求里写什么":前端怎样借助 ABI,像调用普通 JS 方法一样调用合约。
4.1 简要回顾:前端手里的 ABI
ABI(Application Binary Interface,应用二进制接口)是合约的"接口说明书":一份 JSON,写明合约有哪些函数、参数和返回值类型、会发出哪些事件和错误。链上只存字节码,没有函数名,前端必须先有这份说明书才能调用合约。
ABI 由编译合约自动生成,部署后由 export-abi.sh 复制到前端的 src/abi/MyToken.abi.json,合约地址则写入同目录的 deployments.json。
ABI 的 JSON 结构、函数选择器、calldata 编码与解码原理,见第 2 篇的「ABI:合约的"接口说明书"」一章,本章只讲前端怎么用。前端使用 ABI 有两个要点:
- 不需要写全,只要包含用到的条目。 第八章的 30 行 Demo 只写了
balanceOf和transfer两条"人类可读 ABI"(Human-Readable ABI,用函数签名字符串代替 JSON),ethers 会把它解析成等价的结构。 - 必须与合约真实对应,从编译产物导出,不要手抄。 写错函数名或参数类型会算出错误的选择器,调用直接失败(真实报错示例见第 2 篇「ABI 必须与合约一致」一节)。本项目的
make deploy-*每次部署后都会自动执行export-abi.sh,同时更新 ABI 和合约地址。
4.2 new Contract(地址, ABI, runner) 之后发生了什么
只用 ethers 的编码、解码函数也能完成所有操作,但每次调用都要手动"编码 → 发请求 → 解码"。Contract 把这三步合并成了一个普通的 JS 方法调用:
// src/utils/web3/token.ts
function token(address: string, runner: ContractRunner): MyToken {
return new Contract(address, abi, runner) as unknown as MyToken;
}
三个参数分别回答三个问题:
| 参数 | 回答的问题 | 本项目中的值 |
|---|---|---|
address | 找谁:调用链上哪个合约 | 0xcbee…2447(从 deployments.json 读取) |
abi | 它会什么:有哪些函数、事件、错误,怎么编码和解码 | MyToken.abi.json |
runner | 由谁执行:只能读,还是也能写 | 读余额时传 Provider,转账时传 Signer |
第一步:根据 ABI 动态生成方法。 new Contract 会遍历 ABI,为每个 function 条目在对象上挂一个同名方法(真实输出):
target: 0xcbee6a188946136b460c833b238eb5665e1f2447
函数: allowance, approve, balanceOf, decimals, name, symbol, totalSupply, transfer, transferFrom(均为 function)
filters.Transfer: function ← 事件过滤器,用于查询 / 订阅 Transfer 事件
interface ← 负责编码解码的 Interface 对象(原理见第 2 篇的 ABI 一章)
第二步:调用方法时,按 stateMutability 走两条路。 stateMutability 是 ABI 里标记函数"会不会改状态"的字段:view 只读,nonpayable 会写。
contract.balanceOf(addr) contract.transfer(to, amount)
│ ABI:view │ ABI:nonpayable
▼ ▼
① 编码 calldata:0x70a08231 + addr ① 编码 calldata:0xa9059cbb + to + amount
② runner.call({ to: 合约, data }) ② 组装交易 { to: 合约, data }
→ JSON-RPC eth_call ③ runner.sendTransaction(tx)
③ 按 outputs 解码返回值 → 估算 Gas → MetaMask 弹窗签名 → 广播
④ 返回 6000000000000000000000n ④ 返回 ContractTransactionResponse(含 tx.hash)
第三步:runner 决定"能不能写"。 同一个合约,传入不同的 runner,能做的事情不同(真实运行结果):
| runner | 调用 balanceOf(读) | 调用 transfer(写) |
|---|---|---|
Provider(本项目的 getProvider()) | ✅ 返回 6000.0 MTK | ❌ UNSUPPORTED_OPERATION - contract runner does not support sending transactions |
Signer(本项目的 getProvider().getSigner()) | ✅ | ✅ 弹出 MetaMask 签名 |
不传(null) | ❌ | ❌ 同上报错 |
所以项目里读余额的 getTokenBalance 传 Provider,转账的 sendTransfer 传 Signer(代码见第五章步骤 5 和步骤 8)。
附:Contract 上几个实用的"变体"方法
| 写法 | 作用 | 会发交易吗 |
|---|---|---|
contract.transfer(to, amount) | 正常调用 | 是 |
contract.transfer.staticCall(to, amount) | 用 eth_call 模拟执行:能提前知道会不会 revert,不花钱 | 否 |
contract.transfer.estimateGas(to, amount) | 只估算 Gas | 否 |
contract.transfer.populateTransaction(to, amount) | 只生成交易对象,不发送 | 否 |
populateTransaction 的真实输出,清楚地展示了"转账交易"的本质:
{
to: '0xcbee6a188946136b460c833b238eb5665e1f2447', // ← 发给合约,而不是收款人
data: '0xa9059cbb000000000000000000000000455bddc5…0000878678326eac900000'
}
4.3 为什么还要手写一个 TS 接口
ABI 是运行时读进来的 JSON,TypeScript 在编译时不知道 contract.balanceOf 存在、参数是什么类型,new Contract() 返回的对象方法都是 any。本项目手写了一个接口补上类型:
// src/utils/web3/token.ts
interface MyToken {
balanceOf(owner: string): Promise<bigint>;
transfer(to: string, amount: bigint): Promise<ContractTransactionResponse>;
}
这样写错参数类型时,编辑器就能直接提示。真实项目通常用 TypeChain、wagmi CLI 等工具从 ABI 自动生成类型,避免手写接口和合约不一致。
4.4 单位换算:为什么金额一定要用 bigint
链上 1 MTK = 10¹⁸ 个最小单位,100 万 MTK = 10²⁴。JS 的 number 只能精确表示到 2⁵³ ≈ 9×10¹⁵,直接用 number 会丢精度,所以金额一律用 bigint(JS 内置的任意精度整数类型,字面量以 n 结尾):
// src/utils/web3/token.ts
export const DECIMALS = 18;
/** 用户输入的金额(如 "12.5")→ 链上最小单位 */
export const toUnits = (value: string): bigint => parseUnits(value, DECIMALS);
/** 链上最小单位 → 可读金额 */
export const fromUnits = (value: bigint): string =>
formatUnits(value, DECIMALS);
toUnits("12.5") → 12500000000000000000n
fromUnits(6000000000000000000000n) → "6000.0"
后端接口里的金额也是字符串("amount": "2500000000000000000000"),前端用 BigInt(row.amount) 转换后再格式化,全程不经过 number。
小结:地址告诉 ethers 找谁,ABI 告诉它怎么编码解码,runner 决定能读还是能写;金额全程用 bigint。
五、★ 链上转账:前端的 12 个步骤
前面的知识在这一章汇合:按用户操作顺序,逐步看一笔转账在前端是怎么实现的。示例场景是在 Sepolia 上"收款人1 转 2500 MTK 给收款人2"。
sequenceDiagram
autonumber
actor U as 用户
participant FE as 前端页面
participant W as MetaMask
participant C as 链(MTK 合约)
participant BE as Go 后端
U->>FE: 点"连接 MetaMask"
FE->>W: eth_requestAccounts
W-->>FE: 账户地址
FE->>W: wallet_switchEthereumChain(不在 Sepolia 时)
FE->>BE: POST /api/users(登记用户,拿昵称)
FE->>C: eth_call balanceOf(我)(免费)
U->>FE: 填对方地址,点"查询余额"
FE->>BE: POST /api/users(登记对方)
FE->>C: eth_call balanceOf(对方)
U->>FE: 填金额,点"转账"
FE->>FE: 表单校验(地址合法、金额 > 0、余额够)
FE->>W: transfer(to, amount) → 请求签名
W->>U: 弹窗:确认交易与 Gas
U->>W: 确认
W->>C: eth_sendRawTransaction
W-->>FE: txHash(进度条:签名并广播)
FE->>C: tx.wait() 等待打包
C-->>FE: 回执 status=1(进度条:打包上链)
loop 每 2 秒
FE->>BE: GET /api/transactions
end
BE-->>FE: 出现该 txHash(进度条:后端已入库)
按时序图,12 个步骤可以分成四段:准备(13:检测钱包、连接、对齐网络)→ 读数据(47:登记用户、读余额、查对方、校验)→ 写链(810:签名广播、等待上链、刷新余额)→ 展示(1112:等待后端入库、列表展示)。
全系列统一编号的完整时序图
上面那张图只画了前端视角。下面这张完整时序图把一次转账从用户点击到列表显示的全部 22 步串了起来,覆盖前端、MetaMask、节点、合约、后端、数据库。全系列文章和项目源码注释里的圆圈编号(例如第 2 篇的 ⑪~⑮、源码里的 ⑮、⑯~⑳)指的都是这张图的编号:
sequenceDiagram
autonumber
actor A as 用户A(发送方)
participant FE as Vue前端 + ethers
participant MM as MetaMask
participant RPC as RPC节点
participant Chain as 区块链网络(mempool / 出块)
participant EVM as EVM:MyToken.sol
participant BE as Go listener
participant DB as MySQL
A->>FE: 填写对方地址 B、金额,点击“转账”
FE->>FE: ABI 编码 calldata = 0xa9059cbb + B + 金额
FE->>RPC: eth_estimateGas / 获取 nonce 和 Gas 价格
FE->>MM: 请求签名,交易的 to = 合约地址(不是 B)
MM->>A: 弹窗:显示 Gas 费并请求确认
A->>MM: 确认(私钥在本地签名,不外传)
MM->>RPC: eth_sendRawTransaction(已签名的交易)
RPC-->>FE: 立即返回 txHash(状态:已广播)
RPC->>Chain: 校验签名、nonce 和 ETH 余额,放入 mempool 并广播
Chain->>EVM: 验证者把交易打包进区块,每个节点都执行一遍
EVM->>EVM: 按函数选择器路由到 transfer,msg.sender = A
EVM->>EVM: 检查余额,不足则 revert(状态回滚,Gas 照扣)
EVM->>EVM: SSTORE:_balances[A] 减少,_balances[B] 增加
EVM->>EVM: emit Transfer(A, B, 金额),写入交易回执的日志
Chain-->>FE: tx.wait() 拿到回执 status = 1(状态:已上链)
RPC-->>BE: WebSocket 推送 Transfer 事件(eth_subscribe logs;未配置 WS 时改为定时轮询)
BE->>RPC: 等够确认数后 eth_getLogs(合约地址, Transfer topic)
RPC-->>BE: 返回日志:topics = [事件签名, A, B],data = 金额
BE->>DB: 解码后 INSERT transactions(带 network;按 chain_id + tx_hash + log_index 幂等)
FE->>BE: GET /api/transactions?address=A,以及 ?address=B
BE-->>FE: 返回记录,两个列表都显示这笔转账
FE->>RPC: eth_call balanceOf(A)、balanceOf(B) 刷新余额(免费)
对照着看:
| 编号 | 发生在哪里 | 对应本系列 |
|---|---|---|
| ①~⑧ | 前端 → MetaMask → 节点:编码、估算 Gas、签名、广播 | 本章步骤 7~8 |
| ⑨~⑩ | 节点与区块链网络:进入交易池、被打包进区块 | — |
| ⑪~⑭ | 合约(EVM)执行:路由到 transfer、检查余额、改账本、发出事件 | 第 2 篇「转账时合约里发生了什么」 |
| ⑮ | 前端 tx.wait() 拿到回执 | 本章步骤 9 |
| ⑯~⑲ | 后端收到推送、确认后查询日志、写入数据库 | 第 4 篇「链上转账:后端的 8 个步骤」 |
| ⑳~㉒ | 前端查询交易记录、刷新余额 | 本章步骤 10~12 |
注意:这里的圆圈编号 ①~㉒ 与本章的"步骤 1~12"是两套编号,前者是全流程,后者只是前端的操作顺序。
步骤 1:检测钱包是否安装
// src/utils/web3/wallet.ts
export function hasWallet(): boolean {
return typeof window !== "undefined" && !!window.ethereum;
}
没装 MetaMask 时,页面顶部显示"未检测到 MetaMask","连接"按钮置灰。交易列表仍然能看,因为列表来自后端,不需要钱包。
步骤 2:连接钱包(获取账户地址)
// src/utils/web3/wallet.ts
export async function requestAccount(): Promise<string> {
const accounts = (await wallet().request({
method: "eth_requestAccounts",
})) as string[];
return accounts[0];
}
// src/hooks/useWallet.ts
async function connect(
getAccount: () => Promise<string>,
net: NetworkInfo,
onConnected?: () => Promise<void>,
) {
connecting.value = true;
try {
account.value = await getAccount(); // ① 拿到地址 = "登录成功"
removeLocal(DISCONNECTED_KEY); // ② 清除"用户主动断开过"的标记
await ensureNetwork(net); // ③ 确保钱包在页面所选的网络上
await onConnected?.(); // ④ 页面自己的后续动作:登记用户、读余额
} finally {
connecting.value = false;
}
}
Web3 没有"注册"和"密码":钱包返回地址,就证明用户拥有这个地址的私钥,这就是登录。
步骤 3:确保钱包在正确的网络上
用户可能在钱包里选的是主网,而页面选的是 Sepolia。发错链的交易会失败,甚至在主网上花真钱,所以必须先对齐:
// src/hooks/useWallet.ts
async function ensureNetwork(net: NetworkInfo) {
await syncChainId(); // eth_chainId:钱包当前在哪条链
if (chainId.value !== net.chainId) {
await switchNetwork(net); // 不一致就请求切换
await syncChainId();
}
}
// src/utils/web3/wallet.ts
export async function switchNetwork(net: NetworkInfo): Promise<void> {
const chainId = "0x" + net.chainId.toString(16); // 11155111 → "0xaa36a7"
try {
await wallet().request({
method: "wallet_switchEthereumChain",
params: [{ chainId }],
});
} catch (e) {
// 4902:钱包里还没有这个网络 → 先添加(anvil 本地链第一次用时会走到这里)
if ((e as { code?: number }).code !== 4902) throw e;
await wallet().request({
method: "wallet_addEthereumChain",
params: [
{
chainId,
chainName: net.label,
rpcUrls: [net.rpcUrl],
nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 },
blockExplorerUrls: net.explorer ? [net.explorer] : undefined,
},
],
});
}
}
两条链的配置集中在 settings/networkSetting.ts,合约地址从 abi/deployments.json 按 chainId 读取:
export function getDeployment(network: Network): Deployment | undefined {
return deployments[String(NETWORKS[network].chainId)]; // "11155111" → { address: "0xcbee…", ... }
}
步骤 4:在后端登记用户,拿到昵称
// src/views/home/index.vue
async function loadMe() {
try {
me.value = await ensureUser(account.value); // POST /api/users
} catch (e) {
MessagePlugin.warning(
`昵称与交易记录暂时无法显示:${(e as Error).message}`,
);
}
await refreshBalances();
}
注意:后端失败只是警告,不阻断流程。昵称只是锦上添花,余额和转账不依赖后端。真实请求与返回:
POST /api/users
{"address":"0x83da6626ef0721da9d452a7eedc4f291a3f9d88c"}
{"code":200,"msg":"ok","data":{"id":98,"address":"0x83da6626ef0721da9d452a7eedc4f291a3f9d88c","nickname":"合约持有人","createdAt":"2026-09-26T16:11:34.306+08:00"}}
步骤 5:从链上读余额(读操作,免费)
// src/utils/web3/token.ts
export async function getTokenBalance(
tokenAddress: string,
owner: string,
): Promise<bigint> {
return token(tokenAddress, getProvider()).balanceOf(owner); // runner 是 Provider → 只读
}
这一行实际发出的是一个 eth_call 请求。以查询收款人1的余额为例,真实的 JSON-RPC 报文:
// 请求:data = balanceOf 的选择器 0x70a08231 + 地址(补零到 32 字节)
{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
"to": "0xcbee6a188946136b460c833b238eb5665e1f2447",
"data": "0x70a08231000000000000000000000000f78c997295ae31e35a4e9debaa773d1df9f74bf2"
}, "latest"]}
// 响应:32 字节的 uint256
{"jsonrpc":"2.0","id":1,"result":"0x00000000000000000000000000000000000000000000014542ba12a337c00000"}
ethers 把 0x…14542ba12a337c00000 解码成 6000000000000000000000n,fromUnits 后显示为 6000.0 MTK。
eth_call 让节点"模拟执行"一次函数并返回结果,不产生交易、不改状态、不弹钱包、不花 Gas。
步骤 6:查询对方信息
// src/views/home/index.vue
async function bindPeer(address: string) {
const [user, balance] = await Promise.all([
ensureUser(address), // 后端:拿对方昵称(不存在就创建)
getTokenBalance(deployment.value!.address, address), // 链:读对方余额
]);
peer.value = { address: user.address, nickname: user.nickname, balance };
}
两个请求互不依赖,用 Promise.all 并发执行。
步骤 7:表单校验——尽量不发注定失败的交易
链上交易失败也要扣 Gas,所以前端要在发交易前把能发现的错误都拦住:
// src/views/home/index.vue
const rules: FormRules<typeof form> = {
to: [
{ required: true, message: "请输入对方地址" },
{ validator: (v: string) => isAddress(v), message: "不是合法的以太坊地址" }, // ethers 校验格式和校验和
{
validator: (v: string) => v.toLowerCase() !== account.value.toLowerCase(),
message: "不能转给自己",
},
],
amount: [
{ required: true, message: "请输入金额" },
{
validator: (v: string) => (tryToUnits(v) ?? 0n) > 0n,
message: "金额必须是大于 0 的数字(最多 18 位小数)",
},
{
validator: (v: string) =>
myBalance.value === null || (tryToUnits(v) ?? 0n) <= myBalance.value,
message: "超过你的 MTK 余额",
}, // 对应合约里的 ERC20InsufficientBalance
],
};
"转账"按钮还有一道总开关:
/** 已连接 + 钱包在所选网络 + 该网络已部署合约,才能查询和转账 */
const ready = computed(
() => !!account.value && walletOnNetwork.value && !!deployment.value,
);
步骤 8:发送转账——交给钱包签名
// src/utils/web3/token.ts
export async function sendTransfer(
tokenAddress: string,
to: string,
amount: bigint,
): Promise<ContractTransactionResponse> {
const signer = await getProvider().getSigner(); // 拿到 Signer(私钥仍在钱包里)
return token(tokenAddress, signer).transfer(to, amount); // runner 是 Signer → 发交易
}
这一行调用在背后依次发生:ethers 把 transfer(to, amount) 编码成 calldata(0xa9059cbb + to + amount)→ 估算 Gas、获取 nonce 和 Gas 价格 → MetaMask 弹窗显示网络、交互合约、手续费 → 用户确认 → MetaMask 用私钥本地签名(私钥从不离开钱包)→ eth_sendRawTransaction 广播并返回 txHash。完整链路见 3.3 节的转账链路图。
注意交易的 to 是合约地址,而不是收款人:转账的本质是"请 MTK 合约把我账上的一部分记到对方名下",收款人地址只是 calldata 里的一个参数。
用户在弹窗里点"拒绝",这里会抛出 ACTION_REJECTED 错误,页面提示"你在钱包中拒绝了签名"。
步骤 9:等待打包上链
// src/views/home/index.vue(onSubmit 节选)
const tx = await sendTransfer(
deployment.value!.address,
to,
toUnits(form.amount),
);
hash = tx.hash;
lastTx.value = { hash, network: net, stage: TxStageEnum.BROADCAST }; // 进度条第 1 步
MessagePlugin.info("交易已广播,等待打包上链…");
// ⑮ 等待交易被打包并拿到回执
const receipt = await tx.wait();
if (receipt?.status !== 1) throw new Error("交易执行失败");
setStage(hash, TxStageEnum.MINED); // 进度条第 2 步
MessagePlugin.success(`已上链:区块 #${receipt.blockNumber}`);
拿到 tx.hash 只代表"已广播",交易还在等待被打包;tx.wait() 不断查询回执,直到交易进入区块(anvil 约 2 秒,Sepolia 约 12 秒)。回执(Receipt,交易执行结果的凭证)的 status === 1 才代表合约执行成功;为 0 表示合约 revert 了。
步骤 10:刷新双方余额
formRef.value?.reset({ type: "empty", fields: ["amount"] });
await bindPeer(to).catch(() => undefined); // 后端不可用不影响链上结果
await refreshBalances(); // 重新 eth_call 读双方余额
void waitIndexed(hash, to, net); // 不阻塞:后台等待后端入库
交易上链后,链上余额立刻就是新的,所以直接重新读链即可。
步骤 11:等待后端入库
/** 轮询后端,直到这笔交易被 listener 同步入库(⑯~⑳) */
async function waitIndexed(hash: string, address: string, net: Network) {
for (let i = 0; i < 90 && lastTx.value?.hash === hash; i++) {
// 最多等 3 分钟;发了新交易就停止等旧的
const rows = await listTransactions(address, net, contractsFor(net)).catch(
() => [],
);
if (rows.some((r) => r.txHash.toLowerCase() === hash.toLowerCase())) {
setStage(hash, TxStageEnum.INDEXED); // 进度条第 3 步
refreshKey.value++; // 通知两个列表立即刷新
return;
}
await new Promise((r) => setTimeout(r, 2000));
}
}
为什么"上链"和"入库"之间有时间差?后端在 Sepolia 上要等 2 个区块确认(约 24 秒)才入库,防止区块重组导致记录作废。所以进度条会先停在"打包上链",半分钟后才亮起"后端已入库"。
进度条组件 TxLifecycle 把 BROADCAST / MINED / INDEXED 三个阶段映射为步骤条的第 1 / 2 / 3 步,失败时标红停在当前步。
步骤 12:交易列表展示
// src/components/TxList/index.vue
async function load() {
const network = onlyCurrent.value ? props.network : undefined;
// 只显示各网络"当前合约"的记录,重新部署后旧合约的记录不会混进来
rows.value = await listTransactions(
props.address,
network,
contractsFor(network),
);
}
const isOut = (row: TxRecord) => row.from === me.value; // 我是转出方 → "转出"
function counterparty(row: TxRecord) {
if (row.from === ZeroAddress) return { name: "铸造", address: "" }; // 零地址转入 = 铸造
return isOut(row)
? { name: row.toNickname, address: row.to }
: { name: row.fromNickname, address: row.from };
}
同一笔交易,在"我的列表"里显示"转出 -2500",在"对方列表"里显示"转入 +2500":方向是相对于当前查看的地址算出来的,后端只存 from 和 to。列表每 5 秒自动刷新;父组件递增 refreshKey 时立即刷新。
附:用户直接在 MetaMask 里操作时
用户可能不经过页面,直接在 MetaMask 里切换账户或网络。页面要监听这两个事件并跟着变:
// src/hooks/useWallet.ts
onMounted(() => {
if (!installed) return;
window.ethereum!.on("accountsChanged", handleAccountsChanged); // 切换账户 → 重新登记用户、读余额
window.ethereum!.on("chainChanged", handleChainChanged); // 切换网络 → 页面跟随切换
});
onBeforeUnmount(() => {
window.ethereum?.removeListener("accountsChanged", handleAccountsChanged);
window.ethereum?.removeListener("chainChanged", handleChainChanged);
});
此外,useWallet.ts 的 restore() 会在刷新页面后用 eth_accounts(不会弹窗)自动恢复连接;用户主动断开过(localStorage 里有断开标记)就不恢复。
小结:转账主链路只涉及"钱包 + 链";后端只负责昵称和历史记录,失败也不影响转账。tx.hash 代表已广播,tx.wait() 返回 status === 1 才代表已成功上链,后端入库还要再等确认数。
六、与后端交互:统一请求封装
这一章解决的问题:前端怎样统一地调用后端的 REST 接口(这部分与 Web2 前端基本相同)。后端所有 /api 接口返回统一结构 {code, msg, data},前端在一个地方统一处理:
// src/utils/http/index.ts
export async function http<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(BASE + path, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
} catch {
// fetch 只有在网络层失败(后端没启动、跨域被拒)时才会抛错
throw new Error(
`无法连接后端 ${BASE || location.origin},请先启动后端(make backend)`,
);
}
const body = (await res.json().catch(() => ({}))) as Partial<ApiResponse<T>>;
if (body.code !== 200) throw new Error(body.msg || `请求失败:${res.status}`);
return body.data as T; // 拆掉外层包装,调用方直接拿到业务数据
}
业务接口按模块放在 src/api/,每个函数只有一两行:
// src/api/transaction.ts
export function listTransactions(
address: string,
network?: Network,
contracts: string[] = [],
limit = 20,
) {
const q = new URLSearchParams({ address, limit: String(limit) });
if (network) q.set("network", network);
if (contracts.length) q.set("contract", contracts.join(","));
return http<TxRecord[]>(`/api/transactions?${q}`);
}
| 接口 | 用途 | 调用时机 |
|---|---|---|
POST /api/users | 登记用户,拿昵称 | 连接钱包、查询对方 |
GET /api/transactions | 交易记录 | 列表每 5 秒;转账后每 2 秒 |
GET /api/networks | 各链同步进度、后端是否在线 | 每 5 秒(useSyncStatus) |
后端地址由 .env.local 的 VITE_API_BASE 决定:开发环境是 http://localhost:8080;生产环境留空,请求同源的 /api,由 nginx 转发给后端。
七、错误处理:把英文报错翻译成人话
Web3 的错误来源比 Web2 多得多:钱包、节点、合约都可能报错,而且都是英文。这一章看前端怎样统一翻译。utils/web3/error.ts:
export function explainError(e: unknown): string {
if (isError(e, 'ACTION_REJECTED')) return '你在钱包中拒绝了签名'
if (isError(e, 'CALL_EXCEPTION')) {
// 合约 revert 时带回 custom error,ethers 会用 ABI 解码出错误名
if (e.revert?.name === 'ERC20InsufficientBalance') return '合约拒绝:MTK 余额不足(ERC20InsufficientBalance)'
if (!e.revert && !e.data) return NO_CONTRACT_HINT // 地址上没有合约代码
return `合约执行失败:${e.revert?.name ?? e.shortMessage}`
}
if (isError(e, 'BAD_DATA') && e.value === '0x') return NO_CONTRACT_HINT
if (isError(e, 'INSUFFICIENT_FUNDS')) return 'ETH 不足以支付 Gas 费'
...
}
| 场景 | ethers 错误码 | 页面提示 |
|---|---|---|
| 用户在钱包里点了"拒绝" | ACTION_REJECTED | 你在钱包中拒绝了签名 |
| 转账金额超过余额(合约 revert) | CALL_EXCEPTION + ERC20InsufficientBalance | 合约拒绝:MTK 余额不足 |
| anvil 没启动 / 合约没部署 | CALL_EXCEPTION 无数据,或 BAD_DATA | 链上没有返回数据:请确认…… |
| 账户没有 ETH 付 Gas | INSUFFICIENT_FUNDS | ETH 不足以支付 Gas 费 |
能解码出
ERC20InsufficientBalance这个名字,是因为合约使用了 OpenZeppelin v5 的 custom error(自定义错误),revert 数据里带着错误选择器,ethers 再用 ABI 反查出错误名。这也是前端需要完整 ABI 的原因之一。解码过程见第 2 篇「ABI 的两大作用:编码与解码」一节。
八、动手 Demo:30 行 HTML 完成一次链上转账
学完原理,动手最能加深理解。抛开 Vue、TDesign 和后端,下面这个单文件就能完成"连接钱包 → 查余额 → 转账",把前端最核心的链上交互浓缩在一起。
新建 demo.html,用浏览器直接打开(需安装 MetaMask 并切换到 Sepolia):
<!doctype html>
<meta charset="utf-8" />
<button id="connect">连接钱包</button>
<p id="info"></p>
<input id="to" placeholder="对方地址 0x..." size="46" />
<input id="amount" placeholder="金额" size="8" />
<button id="send">转账</button>
<script type="module">
import {
BrowserProvider,
Contract,
formatUnits,
parseUnits,
} from "https://cdn.jsdelivr.net/npm/ethers@6.17.0/+esm";
const TOKEN = "0xcbee6a188946136b460c833b238eb5665e1f2447"; // Sepolia 上的 MTK
// 只写用到的两个函数:人类可读 ABI(Human-Readable ABI)
const ABI = [
"function balanceOf(address) view returns (uint256)",
"function transfer(address,uint256) returns (bool)",
];
const $ = (id) => document.getElementById(id);
let signer;
$("connect").onclick = async () => {
const provider = new BrowserProvider(window.ethereum);
signer = await provider.getSigner(); // 触发 eth_requestAccounts
const me = await signer.getAddress();
const bal = await new Contract(TOKEN, ABI, provider).balanceOf(me); // 读:eth_call,免费
$("info").textContent = `${me} 余额:${formatUnits(bal, 18)} MTK`;
};
$("send").onclick = async () => {
const token = new Contract(TOKEN, ABI, signer);
const tx = await token.transfer(
$("to").value,
parseUnits($("amount").value, 18),
); // 写:弹钱包签名
$("info").textContent = `已广播:${tx.hash},等待打包…`;
const receipt = await tx.wait();
$("info").textContent =
`已上链:区块 #${receipt.blockNumber},status=${receipt.status}`;
};
</script>
这 30 行与项目代码的对应关系:
| Demo 中 | 对应项目代码 |
|---|---|
new BrowserProvider(window.ethereum) | utils/web3/wallet.ts 的 getProvider() |
provider.getSigner() | 连接钱包 + 获取 Signer |
new Contract(TOKEN, ABI, provider).balanceOf() | getTokenBalance() |
new Contract(TOKEN, ABI, signer).transfer() | sendTransfer() |
parseUnits / formatUnits | toUnits / fromUnits |
tx.wait() | 等待打包上链 |
项目代码比 Demo 多出来的部分,正是一个"能用"的 DApp(去中心化应用)必须处理的细节:网络校验与切换、表单校验、错误翻译、钱包事件、刷新后恢复连接、与后端配合展示昵称和历史记录。
用 Demo 转账后,如果后端在运行,项目页面的交易列表里同样会出现这笔记录:后端只监听链上事件,不关心交易是从哪个页面发起的。
九、常见问题
Q:前端能拿到用户的私钥吗? 不能,也不应该。私钥始终在 MetaMask 里。前端只能请求签名,签不签由用户在弹窗里决定。
Q:为什么查余额不直接调后端?
链是权威数据源。后端数据有几十秒的延迟(要等确认),而且理论上可能出错。余额这种关键数据直接从链上读最可靠,而且 eth_call 本身就是免费的。
Q:为什么转账不经过后端? 如果经过后端,后端就要持有用户私钥,这违背了 Web3"自己掌控资产"的原则,也会成为安全隐患。交易必须由用户在自己的钱包里签名。
Q:tx.wait() 返回了,为什么列表里还没有?
上链 ≠ 入库。后端要等确认数(Sepolia 2 个块)后才写入数据库,大约再等半分钟。进度条的第 3 步就是在等这个。
Q:用户切换了 MetaMask 账户,页面会怎样?
accountsChanged 事件会触发,页面自动重新登记用户、读取新账户的余额。切换网络同理(chainChanged)。
Q:src/abi/ 下的文件可以手改吗?
不要手改。它们由 contracts/export-abi.sh 在部署后自动生成。重新部署合约后,前端会自动用上新的地址和 ABI。
下一篇预告
到这里,一笔转账已经由用户在钱包里签名、广播,并被打包上链;前端的进度条也停在了"打包上链",等待"后端已入库"亮起。
那么,交易上链之后,后端是怎样发现它、并把它可靠地记录进数据库的? 第 4 篇 后端:监听链上事件并同步到数据库 将讲解:JSON-RPC 与 WebSocket 两种通信方式、如何订阅 Transfer 事件、为什么要等确认数(区块重组)、如何做到重复处理也不出错(幂等),以及服务重启后如何不丢数据。