系列导航

顺序文章定位
1Web3 链上转账完整流程(小白版)零代码,建立整体认识
2智能合约:从零看懂一个代币合约合约源码、ABI、测试、部署
3📍当前:前端:用 MetaMask + ethers 完成链上转账RPC、钱包、Provider/Signer、转账 12 步
4后端:监听链上事件并同步到数据库事件订阅、确认数、幂等、可靠性

读完本篇你将学会

  1. 前端是怎样和区块链"说话"的:RPC、JSON-RPC、MetaMask、ethers 各自扮演什么角色。
  2. 用 new Contract(地址, ABI, runner) 把链上合约变成一个普通的 JS 对象,并分清"读"和"写"。
  3. 一笔 MTK 转账在前端从"连接钱包"到"列表出现记录"的完整 12 个步骤。
  4. 如何把 Web3 特有的英文报错翻译成用户看得懂的提示。
  5. 用 30 行 HTML 独立完成一次链上转账。

前置知识

建议先读第 1 篇(整体流程)和第 2 篇(智能合约)。本篇默认你已经知道:

  • 合约与 MTK 代币:部署在链上的程序,记录"谁有多少 MTK"。
  • ABI:合约的"接口说明书",前端靠它知道怎么调用合约。
  • 函数选择器:函数签名哈希的前 4 字节,例如 transfer 是 0xa9059cbb。
  • Transfer 事件:合约转账成功时写下的日志,后端靠它同步数据。
  • Gas:在链上执行"写"操作要付的手续费。
  • 前端基础:fetch、async/await,能看懂 Vue 3 / TypeScript 代码。

文中代码均摘自本项目 frontend/src/ 源码,请求与返回数据均为真实数据(Sepolia 测试网实测)。


目录

  1. 定位:前端在 Web3 中扮演什么角色
  2. 技术栈与目录结构
  3. 前端如何与链通信:RPC、MetaMask 与 ethers
  4. 用 ABI 调用合约:new Contract
  5. ★ 链上转账:前端的 12 个步骤
  6. 与后端交互:统一请求封装
  7. 错误处理:把英文报错翻译成人话
  8. 动手 Demo:30 行 HTML 完成一次链上转账
  9. 常见问题
  10. 下一篇预告

一、定位:前端在 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 + TypeScript3.5 / 6.0页面框架(<script setup> 组合式 API)
TDesign Vue Next1.20UI 组件库(表单、表格、步骤条)
ethers6.17与链交互的核心库:连接钱包、编码调用、解码结果
Vite8.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?

  1. 操作本身就是"调用函数":eth_call、eth_sendRawTransaction 都是"执行某个动作",而不是"增删改查某个资源",用"方法名 + 参数"表达最自然。
  2. 与传输方式无关:同一套方法可以跑在 HTTP(一问一答)、WebSocket(能收到推送,后端的 eth_subscribe 依赖它)、IPC(本机进程间通信)上。REST 绑定在 HTTP 上,做不到服务器主动推送。
  3. 全网统一标准:geth、anvil、Infura、Alchemy 都实现同一份规范,所以 ethers、MetaMask、forge 能对接任意节点,换节点只需换一个 URL。
  4. 批量请求:一次网络往返查多个数据,协议原生支持。

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请求发往哪里能签名吗典型场景
BrowserProviderwindow.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 付 GasINSUFFICIENT_FUNDSETH 不足以支付 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 / formatUnitstoUnits / 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 事件、为什么要等确认数(区块重组)、如何做到重复处理也不出错(幂等),以及服务重启后如何不丢数据。