系列导航

顺序文章定位
1Web3 链上转账完整流程(小白版)零代码建立整体认识,智能合约在项目中的意义
2智能合约详解:MyToken 与 MiniSwap代币合约、兑换合约(AMM / 恒定乘积)、ABI、测试、部署
3📍当前:前端:多链钱包、转账与链上兑换RPC、钱包(EIP-6963 / TronLink)、ethers / tronWeb、转账 12 步、★ 兑换面板
4后端详解:监听链上事件并同步到数据库事件订阅、确认数、幂等、原生币扫块、SSE 推送

读完本篇你将学会

  1. 前端是怎样和区块链"说话"的:RPC、JSON-RPC、钱包、ethers / tronWeb 各自扮演什么角色。
  2. 用 new Contract(地址, ABI, runner) 把链上合约变成一个普通的 JS 对象,并分清"读"和"写"。
  3. 一个页面怎样同时支持三条链(本地链 anvil、Sepolia、TRON Nile)和两类钱包(MetaMask 等 EVM 钱包、TronLink)。
  4. 一笔转账(原生币 / USDT / MTK)在前端从"连接钱包"到"列表出现记录"的完整 12 个步骤。
  5. ★ 一次链上兑换在前端要处理的全部细节:报价、价格影响、滑点、deadline、授权、两条链的调用差异、乐观记录。
  6. 如何把 Web3 特有的英文报错翻译成用户看得懂的提示,以及新手最容易踩的坑。

前置知识

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

  • 合约:部署在链上的程序。本项目有两个:代币合约 MyToken(MTK) 和兑换合约 MiniSwap(迷你版 Uniswap V2)。
  • ABI:合约的"接口说明书",前端靠它知道怎么调用合约。
  • 函数选择器:函数签名哈希的前 4 字节,例如 transfer 是 0xa9059cbb。
  • 事件(Event):合约执行时写下的日志。Transfer 记录代币转账,Swap 记录兑换,后端靠它们同步数据。
  • Gas:在链上执行"写"操作要付的手续费(TRON 上对应"能量 / 带宽")。
  • 前端基础:fetch、async/await,能看懂 Vue 3 / TypeScript 代码。

文中代码均摘自本项目 frontend/src/ 源码(为便于阅读做了精简,标注"节选");请求、返回与兑换数据均为 Sepolia / Nile 测试网实测。

本项目支持的三条链与资产(前端配置见 settings/networkSetting.ts,合约地址见 abi/deployments.json):

网络chainId钱包页面可转账的资产兑换合约 MiniSwap
本地链(anvil)31337MetaMask 等 EVM 钱包MTK未部署(本地只演示转账)
Sepolia 测试网11155111MetaMask 等 EVM 钱包ETH(原生币)、USDT(6 位小数)、MTK0x6569…eafb
TRON Nile 测试网3448148188TronLinkTRX(原生币,6 位小数)、USDT(6 位)、MTKTT79caqYHYGSXnRBHMeFzaw6hwoNscSiTb

本地链的 NETWORKS.anvil 没有配置 native 字段(源码注释:"只有需要展示余额和转账的网络才配置"),所以页面上本地链只显示 MTK。


目录

  1. 定位:前端在 Web3 中扮演什么角色
  2. 技术栈与目录结构
  3. 前端如何与链通信:RPC、钱包、ethers 与 tronWeb
  4. 用 ABI 调用合约:new Contract
  5. 多链与多钱包:网络配置、钱包发现与会话
  6. ★ 链上转账:前端的 12 个步骤
  7. ★ 链上兑换:SwapPanel 的实现
  8. 与后端交互:REST 请求与 SSE 推送
  9. 错误处理:把英文报错翻译成人话
  10. 动手 Demo:30 行 HTML 完成一次链上转账
  11. 常见坑
  12. 常见问题
  13. 下一篇预告

一、定位:前端在 Web3 中扮演什么角色

这一章先回答:Web3 前端到底负责什么,它和我们熟悉的 Web2 前端有什么不同。

1.1 一句话定位

前端是用户、钱包、区块链、后端之间的"调度员"。 它不保存钱,也不保存私钥;它把用户的操作翻译成钱包能签名的交易、链能执行的调用,再把结果展示出来。它要同时和钱包、区块链、后端三方打交道:

找谁做什么走什么通道花钱吗
钱包获取账户、切换网络、签名交易EVM:provider.request()(EIP-6963 发现的钱包);TRON:window.tron.request() + tronWeb签名本身不花钱
区块链查余额、兑换报价(读)EVM:经钱包转发的 eth_call / eth_getBalance;TRON:tronWeb 调节点 HTTP API免费
区块链转账、授权、兑换(写)钱包签名后广播要付 Gas / 能量
后端昵称、交易记录、兑换记录、同步进度REST:fetch('/api/...');推送:SSE /api/events免费

1.2 与 Web2 前端最大的不同

对比Web2 前端Web3 前端(本项目)
登录账号密码 → 后端发 token连接钱包:拿到地址就等于"登录",无需密码
转账 / 兑换调后端接口,后端改数据库前端直接把交易交给钱包签名,发到链上,后端完全不参与
数据来源全部来自后端余额、报价来自链(权威);历史记录来自后端(副本)
失败原因接口报错用户拒绝签名、Gas 不足、合约 revert、滑点超限、网络不对……
多端差异浏览器兼容链的差异:地址格式、精度、手续费模型、钱包 API 各不相同

关键认知:转账和兑换这两条主链路上都没有后端。就算后端挂了,用户照样能转账、兑换,只是列表暂时不更新。


二、技术栈与目录结构

这一章快速认识项目用了什么、代码放在哪里,后面讲解时会反复引用这些文件。

技术版本作用
Vue 3 + TypeScript3.5 / 6.0页面框架(<script setup> 组合式 API)
TDesign Vue Next1.20UI 组件库(表单、表格、步骤条、弹窗)
ethers6.17EVM 链交互的核心库:连接钱包、编码调用、解码结果;也用它做 TRON 地址的 Base58 编解码
tronWeb由 TronLink 注入TRON 链交互:项目没有安装 tronweb 包,直接用 TronLink 注入的实例(类型声明见 types/global.d.ts)
Vite8.3开发服务器与打包
frontend/src/
├── main.ts / App.vue              # 入口与根组件
├── views/home/index.vue           # ★ 首页:钱包、会话、转账表单、兑换页签、两个交易列表串成完整流程
├── components/
│   ├── WalletModal/index.vue      # 钱包选择弹窗(仿 RainbowKit):第 1 步选钱包,第 2 步选账户
│   ├── WalletInfo/index.vue       # "我的钱包"卡片:各资产余额、合约地址、后端同步进度
│   ├── SwapPanel/index.vue        # ★ 兑换面板:报价、价格影响、滑点、授权并兑换、我的兑换记录
│   ├── TxList/index.vue           # 交易列表("我的"和"对方的"复用)
│   └── TxLifecycle/index.vue      # 转账进度条:签名广播 → 打包上链 → 后端入库
├── hooks/
│   ├── useWallet.ts               # 钱包状态:授权 / 撤销 / 刷新后恢复 / 监听账户与网络切换(EVM、TRON 各一份)
│   ├── useSessions.ts             # 每个网络各自连接哪个账户(持久化到 localStorage)
│   ├── useBackendEvents.ts        # 后端 SSE 推送:全页共用一条 EventSource
│   ├── usePolling.ts              # 推送不可用时的兜底轮询(页面在后台时暂停)
│   └── useSyncStatus.ts           # 后端同步进度与"后端是否在线"
├── api/                           # 后端接口:user.ts / transaction.ts / swap.ts / network.ts
├── utils/
│   ├── http/index.ts              # fetch 封装:统一处理 {code, msg, data}
│   ├── web3/                      # ★ 与链交互(index.ts 统一导出)
│   │   ├── wallet.ts              #   钱包:EIP-6963 发现、TronLink、授权、切换网络、Provider / Signer / tronWeb
│   │   ├── address.ts             #   地址:0x ↔ TRON Base58 互转、按网络校验
│   │   ├── token.ts               #   资产:余额读取、原生币 / 代币转账、单位换算
│   │   ├── swap.ts                #   兑换:报价、储备、授权、执行兑换、滑点与价格影响计算
│   │   └── error.ts               #   错误翻译(ethers 错误码 + TronLink 字符串)
│   ├── format.ts / storage.ts / scrollLock.ts
├── settings/networkSetting.ts     # ★ 三条链的配置、每条链的资产、合约地址查询
├── enums/                         # cacheEnum.ts(缓存键)、txStageEnum.ts(转账阶段)
├── abi/                           # ★ 自动生成,勿手改
│   ├── MyToken.abi.json           #   代币合约 ABI(USDT 与它的 balanceOf / transfer / approve 签名相同,共用)
│   ├── MiniSwap.abi.json          #   兑换合约 ABI
│   └── deployments.json           #   各链 MTK 与 MiniSwap 的地址、部署区块、部署者
└── types/global.d.ts              # window.ethereum / window.tron / tronWeb 的类型声明

分层原则:utils/web3 只管"怎么和链说话",api 只管"怎么和后端说话",hooks 管"状态",components / views 负责把它们串成业务流程。链的差异(EVM vs TRON)尽量收敛在 utils/web3 里,页面只调用 getAssetBalance、sendAsset、quote、executeSwap 这类统一接口。


三、前端如何与链通信:RPC、钱包、ethers 与 tronWeb

这一章要解决的问题:浏览器里的一段 JS,怎样查到链上的余额、把交易发到链上?

浏览器不能直接连上区块链网络里成千上万个节点,它需要一个入口,这个入口就是 RPC 节点;以太坊上双方说话用的"语言"是 JSON-RPC 协议。前端又不直接找节点,而是经过钱包转发,并用 ethers 省去手写报文的麻烦。TRON 的思路相同,只是换成了 TronLink 和 tronWeb。下面按"RPC → 钱包 → ethers → TRON"的顺序逐层讲。

3.1 RPC 与 JSON-RPC 是什么

RPC(Remote Procedure Call,远程过程调用):像调用本地函数一样,去调用另一台机器上的函数。你只管说"调用哪个函数、参数是什么",对方执行完把结果还给你。

JSON-RPC 是一种用 JSON 描述"调用哪个方法、传什么参数"的 RPC 格式。以太坊的每个节点(geth、anvil、Infura 背后的节点……)都提供一组标准化的 JSON-RPC 方法,前端常用的有:

方法作用读 / 写前端哪里用到
eth_chainId当前是哪条链读校验钱包网络(getWalletChainId)
eth_call模拟执行合约的读函数读查代币余额(balanceOf)、兑换报价(getAmountOut)、授权额度(allowance)
eth_getBalance查原生币(ETH)余额读getAssetBalance 读 ETH 余额
eth_estimateGas预估交易要花多少 Gas读ethers 发交易前自动调用
eth_sendRawTransaction广播一笔已签名的交易写钱包签名后调用
eth_getTransactionReceipt查交易回执(是否上链、成功与否)读tx.wait()
eth_blockNumber最新区块号读tx.wait() 判断确认

后端还会用到 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(本机进程间通信)上。
  3. 全网统一标准:geth、anvil、Infura、Alchemy 都实现同一份规范,所以 ethers、MetaMask、forge 能对接任意节点,换节点只需换一个 URL。
  4. 批量请求:一次网络往返查多个数据,协议原生支持。

3.3 钱包:既转发 RPC,又负责签名(以 MetaMask 为例)

MetaMask 本身不是区块链节点。它为每个网络保存了一个 RPC 地址(在 MetaMask 的"设置 → 网络"里可以看到),然后扮演两个角色:RPC 转发者(把只读请求原样转发给节点)和签名者(需要私钥的操作先弹窗让用户确认,签名后再发给节点)。打个比方:钱包像银行柜员,查询业务直接转给后台;取钱业务必须先让你本人签字。

页面怎么和钱包说话? 钱包对网页暴露一个遵循 EIP-1193(以太坊改进提案 1193,规定了钱包对网页暴露的标准接口)的 Provider 对象,所有交互都通过它的 request() 方法完成。传统做法是读 window.ethereum;本项目改用 EIP-6963 让每个钱包主动"报到",拿到各自的 Provider(第五章详解)。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" }); // 钱包自己处理:弹窗授权

本项目还用到 eth_accounts(获取已授权账户,不弹窗)、wallet_requestPermissions(强制弹出账户勾选框,用于"授权更多账户")、wallet_revokePermissions(撤销授权,即断开连接,EIP-2255)、wallet_switchEthereumChain / wallet_addEthereumChain(切换 / 添加网络)。

钱包收到请求后,按方法分成三类处理:

方法钱包怎么处理会弹窗吗
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)
  │ ③ 弹窗:显示网络、合约地址、手续费
  │ ④ 用户点"确认"
  │ ⑤ 用私钥在本地签名(私钥从不离开钱包)
  │ ⑥ eth_sendRawTransaction(已签名)───────────────────────────▶ RPC 节点 → 全网广播
  │ ⑦ 返回 txHash
  ▼
页面:tx.hash
  │ ⑧ 反复 eth_getTransactionReceipt ──────────────────────────▶ 转发给节点,直到拿到回执

"添加网络"就是在告诉钱包用哪个 RPC。钱包里没有 anvil 网络时,本项目会调用 wallet_addEthereumChain,传入 chainId: '0x7a69'、rpcUrls: ['http://127.0.0.1:8545'](代码见第六章步骤 3)。之后钱包就知道:在"本地链"上,所有请求都发往本机的 anvil。这也解释了为什么 anvil 没启动时页面会报"链上没有返回数据":钱包把请求转发到 8545 端口,那里却没有节点在监听。

3.4 ethers:Provider 与 Signer

有了钱包,前端理论上已经能用 request() 手写所有报文,但编码参数、解码结果、编排多个请求都很繁琐。ethers 就是来做这些脏活的 JS 库。它把"和链打交道"的对象分成两种(类比:Provider 是只能查账的访客,Signer 是拿着印章、能办转账的账户本人):

对比Provider(提供者)Signer(签名者)
能做什么只读:查余额、查区块、查回执读 + 写:还能签名并发送交易
需要私钥吗不需要需要(私钥在钱包里,由钱包代签)
本项目中getProvider()getSigner(account)
// src/utils/web3/wallet.ts(节选)
/** 每次新建 Provider:切换网络后,旧 Provider 缓存的链信息会失效(ethers 会抛 network changed) */
export function getProvider(): BrowserProvider {
  return new BrowserProvider(wallet("evm"));
}

/** 指定账户的签名者:交易的 from 就是这个账户,不一定是钱包里当前选中的那个 */
export function getSigner(account: string) {
  return getProvider().getSigner(account);
}

注意 getSigner(account) 显式传入了账户:MetaMask 可以同时授权多个账户(例如"部署者"和"Anvil-A"),页面为每个网络单独选定其中一个(第五章),签名时以它为 from,而不是用钱包里"当前选中"的那个。

ethers 的 Provider 就是一个 RPC 客户端:你调用它的方法,它负责拼 JSON-RPC 报文、发送请求、解析结果。按"请求发往哪里",常用的 Provider 有三种:

Provider请求发往哪里能签名吗典型场景
BrowserProvider浏览器钱包注入的 EIP-1193 Provider能(由钱包签名)本项目前端
JsonRpcProvider直接发往一个 HTTP RPC 地址不能(除非配私钥)只读页面、Node.js 脚本
WebSocketProvider直接发往一个 WebSocket RPC 地址不能(除非配私钥)需要实时推送的场景

本项目为什么选 BrowserProvider?

  • 交易必须由钱包签名,只能通过钱包的 Provider。
  • 读写走同一条通道:钱包切到哪条链,读到的数据就来自哪条链,不会出现"钱包在 Sepolia、读余额却读的是主网"这种错位。
  • 不暴露 API Key:前端代码会被下载到每个用户的浏览器里,写在里面的 Infura Key 等于公开。借用钱包自己的节点,前端就不需要任何 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(交给钱包签名,钱包再发 eth_sendRawTransaction)
signer.sendTransaction({ to, value })(原生币转账)同上,但 data 为空、value 为金额
tx.wait()轮询 eth_getTransactionReceipt,直到拿到回执

可见 ethers 帮你做了两件事:一是把函数调用翻译成 JSON-RPC 报文(根据 ABI 编码 calldata,把十六进制结果解码成 bigint);二是把多个 RPC 调用编排成完整流程(一句 contract.transfer() 背后是估算 Gas、请求签名、广播交易好几步)。

TRON(波场)的虚拟机 TVM 兼容 EVM,Solidity 合约几乎可以原样部署,但钱包、地址、手续费、节点接口都不同。本项目在 Nile 测试网上用 TronLink 钱包:

对比EVM(Sepolia / anvil)TRON(Nile)
钱包注入EIP-6963 宣告的 Provider(兜底 window.ethereum)window.tron(TIP-1193,约定与 EIP-1193 一致)
调合约的库ethers(BrowserProvider + Contract)TronLink 授权后注入的 window.tron.tronWeb
节点接口JSON-RPC(eth_call …)TRON 全节点 HTTP API(读合约 triggerconstantcontract、读 TRX 余额 getaccount)
地址0x + 40 位十六进制T 开头的 Base58Check(第五章)
原生币精度ETH 18 位(1 ETH = 10¹⁸ wei)TRX 6 位(1 TRX = 10⁶ sun)
手续费Gas(gasLimit × Gas 价格)能量 + 带宽,不足时燃烧 TRX;用 feeLimit 限制最多烧多少
防重放nonce没有 nonce,用引用区块

TronLink 的 window.tron 同样支持 request({ method: 'eth_requestAccounts' })(返回 T 开头地址)和 wallet_switchEthereumChain,所以"授权、切换网络"两类钱包共用同一套函数;但读写合约走的是 tronWeb:

// src/utils/web3/wallet.ts(节选)
/** TronLink 授权后注入的 tronWeb(已连到钱包当前所选网络);未授权时不可用 */
export function getTronWeb(): InjectedTronWeb {
  const tw = window.tron?.tronWeb;
  if (!tw) throw new Error("TronLink 尚未授权本站点,请先连接 TronLink");
  return tw;
}

tronWeb 的合约调用写法和 ethers 很像,只是读用 .call()、写用 .send(options):

// 读(免费):triggerconstantcontract
await getTronWeb().contract(abi, "T...合约").balanceOf(owner).call();
// 写(签名 + 手续费):TronLink 弹窗 → 签名 → 广播,返回交易 ID(不带 0x)
await getTronWeb()
  .contract(abi, "T...合约")
  .transfer(to, amount.toString())
  .send({ feeLimit: TRON_FEE_LIMIT });

小细节:传给 tronWeb 的 ABI 只保留 type === 'function' 的条目(abi.filter((item) => item.type === 'function')),源码注释的原因是"custom error 是较新的 Solidity 特性,旧版 tronWeb 解析 ABI 时不认识"。

3.6 小结:RPC 在项目中的位置

前端用到的节点如下,前端代码里没有写任何 Infura 地址:

节点地址前端怎么用到它
MetaMask 内置的 Sepolia 节点由钱包自己配置页面选 Sepolia 时,所有请求经钱包转发到这里
anvil(本地链)http://127.0.0.1:8545通过 wallet_addEthereumChain 告诉钱包这个地址,之后经钱包转发
publicnode(公共节点)https://ethereum-sepolia-rpc.publicnode.com(可用 VITE_SEPOLIA_PUBLIC_RPC 覆盖)只在"向钱包添加 Sepolia 网络"时作为参数传入
TronLink 内置的 Nile 节点由 TronLink 配置(tronWeb.fullNode.host)页面选 Nile 时,tronWeb 的所有请求发往这里

需要 API Key 的 Infura / TronGrid 节点只由后端访问(见第 4 篇)。项目里共有三条通信线路:前端 ↔ 链经钱包转发,读写同一条通道;后端 ↔ 链直连节点,HTTP 用于查询,WebSocket 用于订阅推送;前端 ↔ 后端是 REST 接口加一条 SSE 推送通道,和区块链无关。


四、用 ABI 调用合约:new Contract

上一章解决了"怎么把请求送到链上",这一章解决"请求里写什么":前端怎样借助 ABI,像调用普通 JS 方法一样调用合约。

4.1 简要回顾:前端手里的 ABI

ABI(Application Binary Interface,应用二进制接口)是合约的"接口说明书":一份 JSON,写明合约有哪些函数、参数和返回值类型、会发出哪些事件和错误。链上只存字节码,没有函数名,前端必须先有这份说明书才能调用合约。

ABI 由编译合约自动生成,部署后由脚本复制到前端的 src/abi/(MyToken.abi.json、MiniSwap.abi.json),合约地址则写入同目录的 deployments.json:

// src/abi/deployments.json(节选):按 chainId 索引
"11155111": {
  "address": "0xcbee6a188946136b460c833b238eb5665e1f2447",   // MTK
  "owner": "0x83da6626ef0721da9d452a7eedc4f291a3f9d88c",
  "startBlock": 11784949,
  "swap": { "address": "0x65691a568b9ea2075325ffc4433c0f4f4c9eeafb", "startBlock": 11811830 }  // MiniSwap
}

ABI 的 JSON 结构、函数选择器、calldata 编码与解码原理,见第 2 篇的「ABI:合约的"接口说明书"」一章,本章只讲前端怎么用。前端使用 ABI 有两个要点:

  • 不需要写全,只要包含用到的条目。 第十章的 30 行 Demo 只写了 balanceOf 和 transfer 两条"人类可读 ABI"(Human-Readable ABI,用函数签名字符串代替 JSON),ethers 会把它解析成等价的结构。
  • 必须与合约真实对应,从编译产物导出,不要手抄。 写错函数名或参数类型会算出错误的选择器,调用直接失败(真实报错示例见第 2 篇「ABI 必须与合约一致」一节)。
  • 标准接口可以复用 ABI:USDT(第三方合约,不在 deployments.json 里)的 balanceOf / transfer / approve / allowance 与 MyToken 签名相同,所以项目直接用 MyToken.abi.json 调 USDT。

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找谁:调用链上哪个合约例如 Sepolia MTK 0xcbee…2447(从 deployments.json 读取)
abi它会什么:有哪些函数、事件、错误,怎么编码和解码MyToken.abi.json / MiniSwap.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 会写,payable 会写且能附带原生币(MiniSwap 的 swap 就是 payable)。

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 → 钱包弹窗签名 → 广播
④ 返回 6000000000000000000000n                    ④ 返回 ContractTransactionResponse(含 tx.hash)

第三步:runner 决定"能不能写"。 同一个合约,传入不同的 runner,能做的事情不同(真实运行结果):

runner调用 balanceOf(读)调用 transfer(写)
Provider(本项目的 getProvider())✅ 返回 6000.0 MTK❌ UNSUPPORTED_OPERATION - contract runner does not support sending transactions
Signer(本项目的 getSigner(account))✅✅ 弹出钱包签名
不传(null)❌❌ 同上报错

附: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 自动生成类型,避免手写接口和合约不一致(swap.ts 里对 MiniSwap 没有手写接口,返回值用 as Promise<bigint> 断言)。

4.4 单位换算:为什么金额一定要用 bigint

链上 1 MTK = 10¹⁸ 个最小单位,100 万 MTK = 10²⁴。JS 的 number 只能精确表示到 2⁵³ ≈ 9×10¹⁵,直接用 number 会丢精度,所以金额一律用 bigint(JS 内置的任意精度整数类型,字面量以 n 结尾)。

现在一条链上有多种资产,精度各不相同(MTK、ETH 18 位;USDT、TRX 6 位),所以换算函数要带上精度参数,精度来自资产配置 AssetInfo.decimals:

// src/utils/web3/token.ts
/** 用户输入的金额(如 "12.5")→ 链上最小单位 */
export const toUnits = (value: string, decimals: number): bigint =>
  parseUnits(value, decimals);
/** 链上最小单位 → 可读金额 */
export const fromUnits = (value: bigint, decimals: number): string =>
  formatUnits(value, decimals);

/** 解析失败(非数字、小数位超过精度)时返回 undefined,供表单校验使用 */
export function tryToUnits(
  value: string,
  decimals: number,
): bigint | undefined {
  try {
    return toUnits(value, decimals);
  } catch {
    return undefined;
  }
}
toUnits("12.5", 18)                     → 12500000000000000000n   (12.5 MTK)
toUnits("12.5", 6)                      → 12500000n               (12.5 USDT / TRX)
fromUnits(6000000000000000000000n, 18)  → "6000.0"
toUnits("0.1234567", 6)                 → 抛错(小数位超过精度),tryToUnits 返回 undefined

后端接口里的金额也是字符串("amount": "2500000000000000000000"),前端用 BigInt(row.amount) 转换后再格式化,全程不经过 number。

小结:地址告诉 ethers 找谁,ABI 告诉它怎么编码解码,runner 决定能读还是能写;金额全程用 bigint,并且一定要用该资产自己的精度换算。


五、多链与多钱包:网络配置、钱包发现与会话

一个页面同时支持三条链、两类钱包,最容易写成满屏的 if (tron) … else …。本项目把差异收敛在三处:网络配置(每条链有什么资产)、钱包层(怎么找到钱包)、地址层(地址长什么样)。页面层再用按网络的会话记住"每条链用哪个账户"。

5.1 网络与资产配置:settings/networkSetting.ts

每条链一份 NetworkInfo,其中 chain 字段('evm' | 'tron')决定用哪类钱包签名、地址长什么样:

// src/settings/networkSetting.ts(节选)
export const NETWORKS: Record<Network, NetworkInfo> = {
  anvil: { key: 'anvil', label: '本地链', chainId: 31337, chain: 'evm', rpcUrl: 'http://127.0.0.1:8545', ... },
  sepolia: {
    key: 'sepolia', label: 'Sepolia', chainId: 11155111, chain: 'evm',
    native: { symbol: 'ETH', decimals: 18 },
    // Sepolia 上没有 Tether 官方部署的 USDT,测试时通用 Aave 测试网的 USDT(6 位精度)
    tokens: [{ symbol: 'USDT', category: '稳定币', decimals: 6, address: '0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0' }],
    explorer: 'https://sepolia.etherscan.io', ...
  },
  nile: {
    key: 'nile', label: 'Nile', chainId: 3448148188, chain: 'tron', // 0xcd8690dc
    // TRX 的最小单位是 sun,1 TRX = 10^6 sun
    native: { symbol: 'TRX', decimals: 6 },
    tokens: [{ symbol: 'USDT', category: '稳定币', decimals: 6, address: '0xeca9bc828a3005b9a3b909f2cc5c2a54794de05f' }],
    explorer: 'https://nile.tronscan.org', ...
  },
}

页面上"可转账的资产"由 assetsOf(network) 拼出来,顺序固定为原生币 → 第三方代币(USDT)→ MyToken(已部署时):

// src/settings/networkSetting.ts(节选)
export const NATIVE_ADDRESS = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";

export function assetsOf(network: Network): AssetInfo[] {
  const net = NETWORKS[network];
  const mtk = getDeployment(network);
  return [
    ...(net.native
      ? [
          {
            ...net.native,
            category: "原生币" as const,
            address: NATIVE_ADDRESS,
            native: true,
          },
        ]
      : []),
    ...(net.tokens ?? []),
    ...(mtk?.address
      ? [
          {
            symbol: "MTK",
            category: "代币" as const,
            decimals: 18,
            address: mtk.address.toLowerCase(),
          },
        ]
      : []),
  ];
}

两个值得注意的约定:

  • 原生币没有合约地址,但交易表、查询接口都按"合约"区分资产,所以用业界常见的 0xEeee…EEeE 占位(与后端 config.NativeAddress 一致)。MiniSwap 合约里原生币则用 address(0) 表示,前端在 swap.ts 里做转换(第七章)。
  • TRON 合约地址在配置里也存成小写 0x 形式,便于和后端对齐;与钱包 / 合约交互时再转成 Base58。

5.2 地址格式:0x 与 TRON Base58

TRON 地址看起来和以太坊完全不同,但底层是同一个 20 字节(同一个私钥在两条链上的 20 字节相同),只是编码不同:

TRON 地址 = Base58( 0x41 + 20 字节 + sha256(sha256(0x41 + 20 字节)) 的前 4 字节校验和 )
// src/utils/web3/address.ts(节选)
/** 0x 形式 → TRON Base58 地址 */
export function hexToTron(hex: string): string {
  const payload = concat([TRON_PREFIX, hex]); // TRON_PREFIX = '0x41'
  return encodeBase58(concat([payload, checksum(payload)]));
}

/** 页面 / 钱包使用的地址 → 后端使用的小写 0x 形式 */
export function toApiAddress(network: Network, address: string): string {
  if (!address) return "";
  return (isTron(network) ? tronToHex(address) : address)?.toLowerCase() ?? "";
}

用它换算项目里的真实地址:

0x 形式(后端 / 配置)TRON Base58(钱包 / 页面)是什么
0xeca9bc828a3005b9a3b909f2cc5c2a54794de05fTXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBfNile USDT
0xd666326b58b01c0902e9b70360ca22dede5411b1TVWr2sxGD42Wb5pseEdxEHVt83QzyqLCeJNile MTK
0xbbfaa366406e2fe989c46828175b92c2388416d3TT79caqYHYGSXnRBHMeFzaw6hwoNscSiTbNile MiniSwap
0x0000000000000000000000000000000000000000T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb零地址(MiniSwap 里代表 TRX)

原则:后端和数据库统一存小写 0x,TRON 地址只在"与钱包 / 合约交互"和"页面展示"时转成 Base58。 对应的函数:toApiAddress(→ 后端)、displayAddress(后端 → 展示)、inputAddress(后端 → 转账输入框,EVM 规范成 EIP-55 校验和形式)、isValidAddress(按网络校验)。

还有一个容易忽略的差异——地址比较:

// src/utils/web3/wallet.ts
/** 同一个地址的比较:EVM 地址大小写只是校验和,不区分;TRON 的 Base58 区分大小写 */
export function sameAddress(chain: ChainKind, a: string, b: string): boolean {
  return chain === "evm" ? a.toLowerCase() === b.toLowerCase() : a === b;
}

5.3 EVM 钱包发现:EIP-6963

老办法的问题:以前所有 EVM 钱包都往 window.ethereum 上注入,装了 MetaMask + OKX + TronLink 时,它们会互相抢占这个全局变量,页面根本不知道拿到的是谁。

EIP-6963(多钱包发现标准) 换成了"点名 + 报到":页面派发 eip6963:requestProvider 事件,每个已安装的钱包各自回应一个 eip6963:announceProvider,附带自己的名称、图标(data URI)、rdns(反向域名标识,如 io.metamask)和它自己的 Provider。这正是 RainbowKit / AppKit 列出"已安装钱包"的原理。

// src/utils/web3/wallet.ts(节选)
const evmWallets = new Map<string, EvmWallet>() // rdns → 钱包;同一个钱包重复宣告时覆盖

function startDiscovery() {
  if (discovering || typeof window === 'undefined') return
  discovering = true
  window.addEventListener('eip6963:announceProvider', (e) => {
    const detail = (e as CustomEvent<EvmWallet>).detail
    if (isTronLink(detail.info)) {
      // TronLink 的以太坊模式也会报到:不列为以太坊钱包,但记下它自带的官方图标
      if (detail.info.icon && detail.info.icon !== tronLinkIcon) { tronLinkIcon = detail.info.icon; ... }
      return
    }
    evmWallets.set(detail.info.rdns, detail)
    walletListeners.forEach((fn) => fn())
  })
}

/** 请求所有钱包重新宣告自己;返回取消订阅函数,供弹窗组件实时刷新列表 */
export function watchEvmWallets(onChange: () => void): () => void {
  startDiscovery()
  walletListeners.add(onChange)
  window.dispatchEvent(new Event('eip6963:requestProvider'))
  return () => walletListeners.delete(onChange)
}

选哪个钱包的优先级(currentEvm()):本次选过的 → 上次选过的(localStorage 记的 rdns,刷新后用同一个钱包恢复)→ MetaMask(io.metamask)→ 第一个可用的。没有任何钱包支持 EIP-6963 时,退回旧式的 window.ethereum(同样排除带 isTronLink 标记的)。

TRON 生态目前没有 EIP-6963 这样的发现标准,只能检测 TronLink 注入的 window.tron。它可能在页面脚本执行之后才注入,注入完成时派发 tronLink#initialized 事件,所以要"等一等":

// src/utils/web3/wallet.ts(节选)
export function waitForWallet(
  chain: ChainKind,
  timeout = 1000,
): Promise<boolean> {
  if (chain === "evm") {
    startDiscovery();
    window.dispatchEvent(new Event("eip6963:requestProvider"));
    // 钱包一般在同一个事件循环内就会回应,给一点余量
    return new Promise((resolve) =>
      setTimeout(() => resolve(hasWallet("evm")), 100),
    );
  }
  if (hasWallet(chain)) return Promise.resolve(true);
  return new Promise((resolve) => {
    const done = () => {
      /* 移除监听、清定时器 */ resolve(hasWallet(chain));
    };
    const timer = setTimeout(done, timeout);
    window.addEventListener("tronLink#initialized", done);
  });
}

TronLink 与 MetaMask 在能力上的几处差异,源码里都做了处理:

能力MetaMaskTronLink项目怎么处理
同时授权多个账户✅ eth_accounts 返回全部❌ 只暴露当前账户 tronWeb.defaultAddress.base58Nile 的连接账户跟随 TronLink 切换
用指定账户签名✅ getSigner(account)❌ 只能用插件里当前选中的账户发交易前比对,不一致直接报错(第十一章)
撤销授权✅ wallet_revokePermissions❌ 不支持页面在 localStorage 记"已断开",刷新后不自动恢复
查询 chainIdeth_chainId新版支持;旧版不支持兜底:按 tronWeb.fullNode.host 是否含 nile 判断

5.5 钱包选择弹窗与按网络的会话

WalletModal(仿 RainbowKit)分两步:

  1. 选钱包:只列出当前网络可用的钱包——本地链 / Sepolia 列 EIP-6963 发现的 EVM 钱包,Nile 列 TronLink。每个钱包显示四级状态:未安装(给下载链接)→ 已安装 → 已授权 → 已连接。
  2. 选账户:钱包授权了多个账户时(例如 MetaMask 同时授权了"部署者"和"Anvil-A"),为当前网络选定其中一个。

为什么要"按网络"记账户? 钱包层面只有"本站点被授权使用哪些账户",并没有"按网络连接"的概念。但开发时很常见的需求是:本地链用 anvil 预置的 Anvil-A,Sepolia 用部署者,Nile 用 TronLink 账户,互不影响。useSessions 就在页面层为每个网络记住选中的账户:

// src/hooks/useSessions.ts(节选)
type Sessions = Partial<Record<Network, string>>   // { anvil: '0x…', sepolia: '0x…', nile: 'T…' }

export function useSessions() {
  const sessions = ref<Sessions>(load())            // 从 localStorage 的 mini-dapp:sessions 读取
  function set(network: Network, account: string) { sessions.value = { ...sessions.value, [network]: account }; save() }
  /** 断开某个网络:只清除它自己的记录,不影响其他网络 */
  function clear(network: Network) { ... }
  return { sessions, set, clear }
}

页面判断"某个网络是否已连接"时,会话记录和钱包授权列表必须同时满足:

// src/views/home/index.vue(节选)
/** 某个网络连接的账户:记录过、且仍在钱包的授权列表里,才算已连接 */
function accountOf(n: Network): string {
  const chain = NETWORKS[n].chain;
  const saved = sessions.sessions.value[n];
  return (
    (saved &&
      wallets[chain].accounts.value.find((a) =>
        sameAddress(chain, a, saved),
      )) ||
    ""
  );
}
/** 当前网络的账户:钱包返回的原始形式(EVM 为 0x,TRON 为 T 开头),用于签名、读余额和展示 */
const account = computed(() => accountOf(network.value));
/** 同一个账户的后端形式(小写 0x),用于登记用户、查询交易记录 */
const apiAccount = computed(() => toApiAddress(network.value, account.value));

用户在钱包里撤销了某个账户的授权,用它的网络就自动变为"未连接"。"断开"也只断当前网络:同一个钱包已经没有任何网络在用时,才真正撤销钱包对本站点的授权。

小结:useWallet(chain) 维护"钱包允许本站点用哪些账户"(EVM、TRON 各一份),useSessions 维护"每个网络用哪个账户",两者一交叉就是 account;account 用于和链交互,apiAccount 用于和后端交互。


六、★ 链上转账:前端的 12 个步骤

前面的知识在这一章汇合:按用户操作顺序,逐步看一笔转账在前端是怎么实现的。示例场景是在 Sepolia 上"收款人1 转 2500 MTK 给收款人2";原生币(ETH / TRX)和 USDT 的差异在对应步骤里单独说明。

sequenceDiagram
  autonumber
  actor U as 用户
  participant FE as 前端页面
  participant W as 钱包(MetaMask / TronLink)
  participant C as 链(代币合约 / 原生币)
  participant BE as Go 后端
  U->>FE: 点"连接钱包",在弹窗里选钱包、选账户
  FE->>W: eth_requestAccounts(+ eth_accounts)
  W-->>FE: 账户地址
  FE->>W: wallet_switchEthereumChain(不在所选网络时)
  FE->>BE: POST /api/users(登记用户 + 网络,拿昵称)
  FE->>C: 读余额:eth_call balanceOf / eth_getBalance(免费)
  U->>FE: 选择或输入对方地址(自动查询)
  FE->>BE: POST /api/users(登记对方)
  FE->>C: 读对方余额
  U->>FE: 选资产、填金额,点"转账"
  FE->>FE: 表单校验(地址合法、金额 > 0、余额够)
  FE->>W: transfer(to, amount) 或原生币 {to, value} → 请求签名
  W->>U: 弹窗:确认交易与手续费
  U->>W: 确认
  W->>C: 广播已签名交易
  W-->>FE: txHash(进度条:签名并广播)
  FE->>C: 等待打包(tx.wait / waitTronTx)
  C-->>FE: 执行成功(进度条:打包上链)
  BE-->>FE: SSE 推送 tx 事件(推送不可用时每 10 秒查一次)
  FE->>BE: GET /api/transactions(进度条:后端已入库)

按时序图,12 个步骤可以分成四段:准备(13:检测钱包、连接、对齐网络)→ 读数据(47:登记用户、读余额、查对方、校验)→ 写链(810:签名广播、等待上链、刷新余额)→ 展示(1112:等待后端入库、列表展示)。

全系列统一编号的完整时序图

上面那张图只画了前端视角。下面这张完整时序图(以 EVM 链的代币转账为例)把一次转账从用户点击到列表显示的全部 22 步串了起来,覆盖前端、钱包、节点、合约、后端、数据库。全系列文章和项目源码注释里的圆圈编号(例如第 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: 收到 SSE tx 事件后 GET /api/transactions?address=A / B(推送不可用时 15 秒轮询)
  BE-->>FE: 返回记录,两个列表都显示这笔转账
  FE->>RPC: eth_call balanceOf(A)、balanceOf(B) 刷新余额(免费)

对照着看:

编号发生在哪里对应本系列
①~⑧前端 → 钱包 → 节点:编码、估算 Gas、签名、广播本章步骤 7~8
⑨~⑩节点与区块链网络:进入交易池、被打包进区块—
⑪~⑭合约(EVM)执行:路由到 transfer、检查余额、改账本、发出事件第 2 篇「转账时合约里发生了什么」
⑮前端 tx.wait() 拿到回执本章步骤 9
⑯~⑲后端收到推送、确认后查询日志、写入数据库第 4 篇「链上转账:后端的 8 个步骤」
⑳~㉒前端收到推送、查询交易记录、刷新余额本章步骤 10~12

注意:圆圈编号 ①~㉒ 与本章的"步骤 1~12"是两套编号,前者是全流程,后者只是前端的操作顺序。

原生币转账没有 ⑪~⑭ 这一段:交易的 to 就是收款人、value 就是金额、data 为空,不执行合约代码,也就没有 Transfer 事件,后端要逐块扫描交易才能发现它(见第 4 篇)。TRON 的 ①~⑧ 换成 tronWeb + TronLink,⑮ 换成 waitTronTx 轮询 getTransactionInfo。

步骤 1:检测钱包是否安装

// src/utils/web3/wallet.ts(节选)
function injected(chain: ChainKind): InjectedProvider | undefined {
  if (typeof window === "undefined") return undefined;
  return chain === "evm" ? currentEvm()?.provider : window.tron;
}

export function hasWallet(chain: ChainKind): boolean {
  return !!injected(chain);
}

钱包检测按链类型分开:EVM 看 EIP-6963 有没有钱包报到,TRON 看 window.tron。页面加载时 useWallet(chain).restore() 会先 waitForWallet(chain) 等钱包注入完成再判断。没装钱包时弹窗里显示"未安装,去下载"。交易列表仍然能看,因为列表来自后端,不需要钱包。

步骤 2:连接钱包(获取账户地址)

用户点"连接钱包" → 弹窗选钱包 → 页面请求授权:

// src/hooks/useWallet.ts(节选)
async function authorize(more = false): Promise<string[]> {
  connecting.value = true;
  try {
    bind(); // 把 accountsChanged / chainChanged 监听绑到当前钱包上
    removeLocal(walletDisconnectedKey(chain)); // 用户主动连接:取消之前的"已断开"
    const list = more
      ? await requestMoreAccounts(chain)
      : await requestAccounts(chain);
    // eth_requestAccounts 在部分钱包里只返回当前账户,再用 eth_accounts 拿到完整列表
    accounts.value =
      chain === "evm" ? await getAuthorizedAccounts(chain) : list;
    return accounts.value;
  } finally {
    connecting.value = false;
  }
}
// src/views/home/index.vue(节选)
async function authorize(chain: ChainKind, more = false) {
  try {
    const list = await wallets[chain].authorize(more);
    if (list.length === 1 && !more)
      await pickAccount(list[0]); // 只有一个账户:直接用
    else openModal("account"); // 多个账户:弹窗第 2 步让用户选
  } catch (e) {
    MessagePlugin.error(explainError(e));
  }
}

/** 为当前网络选定账户:记录下来 → 让钱包切到该网络 → 登记用户、读余额 */
async function pickAccount(acc: string) {
  modalVisible.value = false;
  sessions.set(network.value, acc);
  resetChainState();
  try {
    await wallet.value.ensureNetwork(net.value);
  } catch (e) {
    MessagePlugin.warning(explainError(e));
  }
  await loadMe();
}

Web3 没有"注册"和"密码":钱包返回地址,就证明用户拥有这个地址的私钥,这就是登录。

步骤 3:确保钱包在正确的网络上

用户可能在钱包里选的是主网,而页面选的是 Sepolia。发错链的交易会失败,甚至在主网上花真钱,所以必须先对齐:

// src/hooks/useWallet.ts
async function ensureNetwork(net: NetworkInfo) {
  await syncChainId(); // eth_chainId:钱包当前在哪条链
  if (chainId.value !== net.chainId) {
    await switchNetwork(chain, net); // 不一致就请求切换
    await syncChainId();
  }
}
// src/utils/web3/wallet.ts
/** 切换钱包网络;EVM 钱包里还没有该网络(错误码 4902)时先添加。TronLink 内置了 Nile,只需切换 */
export async function switchNetwork(
  chain: ChainKind,
  net: NetworkInfo,
): Promise<void> {
  const chainId = "0x" + net.chainId.toString(16); // 11155111 → "0xaa36a7",3448148188 → "0xcd8690dc"
  try {
    await wallet(chain).request({
      method: "wallet_switchEthereumChain",
      params: [{ chainId }],
    });
  } catch (e) {
    if (chain !== "evm" || (e as { code?: number }).code !== 4902) throw e;
    await wallet(chain).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,
        },
      ],
    });
  }
}

钱包不在所选网络时,页面顶部会显示"xxx 当前不在 Sepolia(chainId 11155111)。点此切换",转账按钮也会置灰(见步骤 7 的 ready)。

步骤 4:在后端登记用户,拿到昵称

// src/views/home/index.vue(节选)
async function loadMe() {
  if (!account.value) {
    me.value = null;
    myBalances.value = {};
    return;
  }
  try {
    // 自己的钱包账户:登记到当前链,后端开始同步它在这条链上的转账(只登记当前链,不影响其他链)
    me.value = await ensureUser(apiAccount.value, network.value);
  } catch (e) {
    MessagePlugin.warning(
      `昵称与交易记录暂时无法显示:${(e as Error).message}`,
    );
  }
  await refreshBalances();
}

两个要点:

  • 后端失败只是警告,不阻断流程。昵称只是锦上添花,余额和转账不依赖后端。
  • 登记时带上 network:后端据此把"这个地址在这条链上"登记下来,开始同步它的原生币和 USDT 转账(全链扫描每个地址不现实,只同步登记过的用户,见第 4 篇)。查询对方时不传 network。TRON 账户传给后端前先用 toApiAddress 转成 0x。

真实请求与返回(早期版本抓包,未带 network 字段):

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:从链上读余额(读操作,免费)

一条链上有多种资产,"读余额"按链类型 × 资产类型分四种情况,统一收在 getAssetBalance 里:

// src/utils/web3/token.ts
export async function getAssetBalance(
  network: Network,
  asset: AssetInfo,
  owner: string,
): Promise<bigint> {
  if (isTron(network)) {
    // TRX 余额是账户本身的属性(getaccount 接口),单位 sun
    if (asset.native) return BigInt(await getTronWeb().trx.getBalance(owner));
    const result = await getTronWeb()
      .contract(tronAbi, hexToTron(asset.address))
      .balanceOf(owner)
      .call();
    // 不同版本的 tronWeb 返回 bigint 或 BigNumber,统一转成字符串再转 bigint
    return BigInt(String(result));
  }
  if (asset.native) return getProvider().getBalance(owner); // eth_getBalance
  return token(asset.address, getProvider()).balanceOf(owner); // eth_call,runner 是 Provider → 只读
}
原生币(ETH / TRX)代币(USDT / MTK)
EVMeth_getBalance:读账户本身的余额eth_call → balanceOf:读合约账本里的记录
TRONgetaccount:读账户的 TRX 余额triggerconstantcontract → balanceOf

页面用 Promise.allSettled 并发读取所有资产,单个资产读取失败不影响其他资产:

// src/views/home/index.vue(节选)
async function readBalances(address: string): Promise<Balances> {
  const n = network.value;
  const results = await Promise.allSettled(
    assets.value.map((a) => getAssetBalance(n, a, address)),
  );
  const out: Balances = {};
  results.forEach((r, i) => {
    if (r.status === "fulfilled") out[assets.value[i].symbol] = r.value;
    else
      MessagePlugin.error(
        `读取 ${assets.value[i].symbol} 余额失败:${explainError(r.reason)}`,
      );
  });
  return out;
}

以查询收款人1的 MTK 余额为例,真实的 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(…, 18) 后显示为 6000.0 MTK。

eth_call 让节点"模拟执行"一次函数并返回结果,不产生交易、不改状态、不弹钱包、不花 Gas。

步骤 6:查询对方信息

"对方地址"是一个可筛选、可创建的下拉框:选项来自后端 GET /api/users/counterparties(这条链上和你在这些资产里打过交道的用户,会排除兑换合约地址——它不是可以转账的"对方"),也可以直接输入新地址回车。选中后自动查询,不必再点"查询余额":

// src/views/home/index.vue(节选)
/** 把右侧列表绑定到对方:后端登记用户(不存在则创建)+ 链上读余额 */
async function bindPeer(address: string) {
  const apiAddress = toApiAddress(network.value, address);
  const [user, balances] = await Promise.all([
    ensureUser(apiAddress),
    readBalances(address),
  ]);
  peer.value = { address, apiAddress, nickname: user.nickname, balances };
}

两个请求互不依赖,用 Promise.all 并发执行。注意 address(页面 / 链使用的形式)和 apiAddress(后端使用的形式)分开存。

步骤 7:表单校验——尽量不发注定失败的交易

链上交易失败也要扣手续费,所以前端要在发交易前把能发现的错误都拦住。校验文案要随网络和资产变化(地址格式、代币符号、精度),所以 validator 返回 { result, message }:

// src/views/home/index.vue(节选)
const rules: FormRules<typeof form> = {
  to: [
    { required: true, message: "请输入对方地址" },
    {
      validator: (v: string) =>
        check(
          isValidAddress(network.value, v),
          net.value.chain === "tron"
            ? "不是合法的 TRON 地址(T 开头)"
            : "不是合法的以太坊地址",
        ),
    },
    {
      validator: (v: string) =>
        toApiAddress(network.value, v) !== apiAccount.value,
      message: "不能转给自己",
    },
  ],
  amount: [
    { required: true, message: "请输入金额" },
    {
      validator: (v: string) => {
        const decimals = asset.value?.decimals ?? 18;
        return check(
          (tryToUnits(v, decimals) ?? 0n) > 0n,
          `金额必须是大于 0 的数字(最多 ${decimals} 位小数)`,
        );
      },
    },
    {
      validator: (v: string) => {
        const a = asset.value;
        const balance = a ? myBalances.value[a.symbol] : undefined;
        return check(
          !a ||
            balance === undefined ||
            (tryToUnits(v, a.decimals) ?? 0n) <= balance,
          `超过你的 ${a?.symbol} 余额`,
        );
      },
    }, // 对应合约里的 ERC20InsufficientBalance
  ],
};

"转账"按钮还有一道总开关:

/** 已连接 + 钱包在所选网络 + 该网络有可转账的资产,才能查询和转账 */
const ready = computed(
  () => !!account.value && walletOnNetwork.value && !!asset.value,
);

"不能转给自己"用 toApiAddress 统一成小写 0x 再比较,EVM 地址的大小写差异、TRON 与 0x 形式差异都不会漏判。

步骤 8:发送转账——交给钱包签名

四种转账(EVM / TRON × 原生币 / 代币)统一收在 sendAsset,返回统一的 SentTransfer { hash, wait() },页面不用关心底层差异:

// src/utils/web3/token.ts(节选)
export async function sendAsset(
  network: Network,
  asset: AssetInfo,
  from: string,
  to: string,
  amount: bigint,
): Promise<SentTransfer> {
  if (isTron(network)) {
    // TronLink 只能用插件里当前选中的账户签名,页面记录的账户与它不一致时直接报错,避免用错账户转账
    if (getTronWeb().defaultAddress.base58 !== from)
      throw new Error(
        "TronLink 当前账户与页面连接的账户不一致,请在 TronLink 中切换回来",
      );
    if (asset.native) return sendTrx(from, to, amount);
    const txId = await getTronWeb()
      .contract(tronAbi, hexToTron(asset.address))
      .transfer(to, amount.toString())
      .send({ feeLimit: TRON_FEE_LIMIT }); // 100 TRX:最多愿意燃烧的 TRX,对应以太坊的 gasLimit
    return { hash: "0x" + txId, wait: () => waitTronTx(txId) };
  }
  // 用页面为该网络选定的账户签名:MetaMask 同时授权了多个账户时,它不一定是钱包里当前选中的那个
  const signer = await getSigner(from);
  const tx = asset.native
    ? await signer.sendTransaction({ to, value: amount }) // 原生币:to 是收款人,value 是金额
    : await token(asset.address, signer).transfer(to, amount); // 代币:to 是合约,收款人在 calldata 里
  return {
    hash: tx.hash,
    async wait() {
      const receipt = await tx.wait();
      if (receipt?.status !== 1) throw new Error("交易执行失败");
      return receipt.blockNumber;
    },
  };
}

以 EVM 代币转账为例,这一行调用在背后依次发生:ethers 把 transfer(to, amount) 编码成 calldata(0xa9059cbb + to + amount)→ 估算 Gas、获取 nonce 和 Gas 价格 → 钱包弹窗显示网络、交互合约、手续费 → 用户确认 → 钱包用私钥本地签名(私钥从不离开钱包)→ eth_sendRawTransaction 广播并返回 txHash。

注意代币转账交易的 to 是合约地址,而不是收款人:转账的本质是"请合约把我账上的一部分记到对方名下",收款人地址只是 calldata 里的一个参数。原生币转账则相反:to 就是收款人。

TRX 转账为了教学,把"构造 → 签名 → 广播"三步拆开写,和以太坊一一对应:

// src/utils/web3/token.ts(节选)
async function sendTrx(
  from: string,
  to: string,
  amount: bigint,
): Promise<SentTransfer> {
  if (amount > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("金额过大"); // tronWeb 这里只收 number
  const tronWeb = getTronWeb();
  const unsigned = await tronWeb.transactionBuilder.sendTrx(
    to,
    Number(amount),
    from,
  ); // 节点构造未签名交易
  const signed = await tronWeb.trx.sign(unsigned); // TronLink 弹窗确认并签名
  const res = await tronWeb.trx.sendRawTransaction(signed); // 广播
  if (!res.result)
    throw new Error(`广播失败:${res.code ?? ""} ${res.message ?? ""}`);
  return { hash: "0x" + signed.txID, wait: () => waitTronTx(signed.txID) };
}

TRON 的交易 ID 本身不带 0x,这里统一补上,与后端返回的一致;生成 TronScan 链接时再去掉(txUrl)。

用户在弹窗里点"拒绝",EVM 会抛 ACTION_REJECTED,TronLink 抛含 declined 等字样的字符串,页面都提示"你在钱包中拒绝了签名"(第九章)。

步骤 9:等待打包上链

// src/views/home/index.vue(onSubmit 节选)
const tx = await sendAsset(
  n,
  a,
  account.value,
  to,
  toUnits(form.amount, a.decimals),
);
hash = tx.hash;
lastTx.value = {
  hash,
  network: n,
  stage: TxStageEnum.BROADCAST,
  walletName: wallet.value.info.value.name,
}; // 进度条第 1 步
MessagePlugin.info("交易已广播,等待打包上链…");

// ⑮ 等待交易被打包并拿到回执
const blockNumber = await tx.wait();
setStage(hash, TxStageEnum.MINED); // 进度条第 2 步
MessagePlugin.success(`已上链:区块 #${blockNumber}`);

拿到 tx.hash 只代表"已广播",交易还在等待被打包。wait() 在两条链上的实现不同:

EVM:tx.wait()TRON:waitTronTx(txId)
怎么等ethers 轮询 eth_getTransactionReceipt每 2 秒调一次 getTransactionInfo,最多 60 次(2 分钟)
成功判断回执 status === 1有 blockNumber,且 receipt.result 为空(TRX 转账)或 SUCCESS
失败判断status === 0:合约 revertresult === 'FAILED' 或 receipt.result 为 REVERT / OUT_OF_ENERGY 等
出块时间anvil 约 2 秒,Sepolia 约 12 秒约 3 秒

回执(Receipt,交易执行结果的凭证)成功才代表合约执行成功;失败的交易同样上了链、同样扣了手续费。

步骤 10:刷新双方余额

// 用表单的 reset 清空金额:直接赋值 '' 会触发"请输入金额"的校验提示
formRef.value?.reset({ type: "empty", fields: ["amount"] });
await bindPeer(to).catch(() => undefined); // 后端不可用不影响链上结果
await refreshBalances(); // 重新读双方余额
void waitIndexed(hash, toApiAddress(n, to), n); // 不阻塞:后台等待后端入库

交易上链后,链上余额立刻就是新的,所以直接重新读链即可。

步骤 11:等待后端入库

旧版本在这里每 2 秒轮询一次后端;现在改成以后端推送为主、低频查询兜底:

// src/views/home/index.vue(节选)
/**
 * 等待这笔交易被 listener 同步入库(⑯~⑳):后端入库后会推送 tx 事件,收到即完成。
 * 兜底:推送断开或事件被丢弃时,每 10 秒查一次后端;Nile 要等 19 个确认、原生币每分钟扫描一次,所以最多等 3 分钟。
 */
function waitIndexed(hash: string, apiAddress: string, n: Network) {
  const same = (h: string) => h.toLowerCase() === hash.toLowerCase();
  let finished = false;
  const finish = () => {
    if (finished) return;
    finished = true;
    off();
    clearInterval(timer);
    clearTimeout(timeout);
    if (lastTx.value?.hash !== hash) return; // 期间又发起了新的转账,不再更新这一笔
    setStage(hash, TxStageEnum.INDEXED); // 进度条第 3 步
    refreshKey.value++; // 通知两个列表立即刷新
  };
  const off = subscribeBackendEvents({ tx: (e) => same(e.txHash) && finish() });
  const check = async () => {
    if (lastTx.value?.hash !== hash) return finish();
    const rows = await listTransactions(apiAddress, n, contractsFor(n)).catch(
      () => [],
    );
    if (rows.some((r) => same(r.txHash))) finish();
  };
  const timer = setInterval(check, 10000);
  const timeout = setTimeout(() => {
    finished = true;
    off();
    clearInterval(timer);
  }, 180000);
  void check(); // 可能在订阅之前就已经入库了(例如本地链不需要等确认),先查一次
}

为什么"上链"和"入库"之间有时间差?后端要等够确认数才入库(Sepolia 2 个区块约 30 秒,Nile 19 个区块约 1 分钟),防止区块重组导致记录作废;原生币转账没有事件,后端要扫块才能发现,会更慢。所以进度条会先停在"打包上链",稍后才亮起"后端已入库"。

进度条组件 TxLifecycle 把 BROADCAST / MINED / INDEXED 三个阶段映射为步骤条的第 1 / 2 / 3 步,失败时标红停在当前步,并显示签名所用的钱包名称(MetaMask / TronLink)。

步骤 12:交易列表展示

// src/components/TxList/index.vue(节选)
async function load() {
  // 按合约过滤:既区分币种,也避免重新部署后旧合约的记录混进来
  const contracts =
    allAssets.value || !props.asset
      ? contractsFor(props.network)
      : [props.asset.address];
  rows.value = await listTransactions(props.address, props.network, contracts);
}

const isOut = (row: TxRecord) => row.from === me.value; // 我是转出方 → "转出"
/** 这条记录是哪种资产:同一条链上可能有 MTK、USDT、ETH,精度各不相同 */
const rowAsset = (row: TxRecord) => assetOf(row.network, row.contract);
function counterparty(row: TxRecord) {
  if (row.from === ZeroAddress) return { name: "铸造", address: "" }; // 零地址转入 = 铸造
  const hex = isOut(row) ? row.to : row.from;
  const address = displayAddress(row.network, hex); // TRON 显示 T 开头
  // 与兑换合约之间的转账是兑换的一部分,标注出来,而不是显示一个随机昵称
  if (hex === swapAddressOf(row.network))
    return { name: "MiniSwap 兑换合约", address };
  return { name: isOut(row) ? row.toNickname : row.fromNickname, address };
}

// 新交易入库时,只有转出方或转入方是本列表的地址、且在当前网络上,才需要刷新
const { connected } = useBackendEvents({
  ready: load,
  tx: (e) => {
    if (
      me.value &&
      e.network === props.network &&
      (e.from === me.value || e.to === me.value)
    )
      void load();
  },
});
usePolling(load, 15000, () => !connected.value); // 推送不可用时退回每 15 秒轮询

几个细节:

  • 同一笔交易,在"我的列表"里显示"转出 -2500",在"对方列表"里显示"转入 +2500":方向是相对于当前查看的地址算出来的,后端只存 from 和 to。
  • 每条记录按 row.contract 找到对应资产,再用该资产的精度格式化金额。
  • 右上角开关:打开时显示当前网络所有币种,关闭时只看转账卡片里选中的资产。
  • 兑换时,你卖出的代币会转给 MiniSwap、买到的代币由 MiniSwap 转来,所以交易列表里会出现"MiniSwap 兑换合约"这个对方。

附:用户直接在钱包里操作时

用户可能不经过页面,直接在钱包里切换账户或网络。useWallet 把监听绑定在当前钱包的 Provider 上(用户在弹窗里从 MetaMask 换成 OKX 时,要从旧 Provider 解绑、绑到新 Provider,否则会收到旧钱包的事件):

// src/hooks/useWallet.ts(节选)
function bind() {
  const p = hasWallet(chain) ? wallet(chain) : undefined;
  info.value = walletInfo(chain);
  installed.value = !!p;
  if (p === bound) return;
  unbind();
  p?.on("accountsChanged", handleAccountsChanged);
  p?.on("chainChanged", handleChainChanged);
  bound = p;
}

async function handleChainChanged(...args: unknown[]) {
  // EVM 钱包传 '0xaa36a7' 这样的字符串,TronLink 传 { chainId: '0xcd8690dc' }
  const raw = args[0] as string | { chainId?: string };
  chainId.value = Number(typeof raw === "object" ? raw?.chainId : raw);
  await events.onChainChanged?.(chainId.value);
}

页面对这两个事件的反应:

事件EVM 钱包TronLink
accountsChanged在 MetaMask 里切换"当前选中账户"不影响任何网络(各网络用自己选定的账户);某账户被撤销授权时,用它的网络自动变为未连接TronLink 只能用插件当前账户签名,所以 Nile 的连接账户跟随它切换
chainChanged切到同一类链的另一个网络时,页面跟随切换并刷新余额同左

此外,restore() 会在刷新页面后用 eth_accounts(不会弹窗)静默恢复两个钱包的授权,各网络再按 useSessions 的记录恢复连接;用户主动断开过(localStorage 里有 mini-dapp:wallet-disconnected:<chain> 标记)就不恢复。

小结:转账主链路只涉及"钱包 + 链";后端只负责昵称和历史记录,失败也不影响转账。hash 代表已广播,wait() 成功才代表已成功上链,后端入库还要再等确认数,完成时由 SSE 推送通知前端。


七、★ 链上兑换:SwapPanel 的实现

转账是"把我的 A 给别人",兑换是"把我的 A 换成 B"。本项目用自己写的 MiniSwap(迷你版 Uniswap V2,合约原理见第 2 篇)实现了原生币 / USDT / MTK 两两互换。这一章是本篇最重要的部分:看一个兑换面板从输入数字到"兑换成功",前端要处理哪些细节。

7.1 先看界面和整体流程

兑换面板在首页右侧卡片的"兑换"页签(components/SwapPanel/index.vue),从上到下是:

区域内容数据从哪来
卖出数量输入框、资产下拉、余额与"最大"余额由首页传入(链上读取)
⇅交换买卖方向—
买入(预计)报价结果、资产下拉合约 getAmountOut(只读)
明细当前价格、价格影响、滑点容忍(0.5% / 1% / 3%)、最少收到、手续费 0.3%合约 getReserves + 前端计算
按钮"输入卖出数量" / "报价中…" / "授权 MTK 并兑换" / "兑换" ……状态机(7.7 节)
我的兑换记录时间、卖出、买入、交易哈希;"确认中"标签后端 /api/swaps + 本地乐观记录

一个真实例子(Sepolia,ETH-MTK 池初始为 0.5 ETH / 5000 MTK):卖出 500 MTK,页面显示预计买入 0.04533 ETH、当前价格 1 MTK ≈ 0.0001000 ETH、价格影响 9.06%(红色)。本章就用这个例子贯穿。

整体流程:

sequenceDiagram
  autonumber
  actor U as 用户
  participant SP as SwapPanel
  participant W as 钱包
  participant S as MiniSwap 合约
  participant T as 代币合约(卖出的代币)
  participant BE as 后端
  U->>SP: 输入卖出数量(停 300ms)
  SP->>S: getReserves + getAmountOut(只读,免费)
  S-->>SP: 储备、预计收到 → 算当前价格、价格影响、最少收到
  U->>SP: 点"授权 MTK 并兑换"
  SP->>T: allowance(我, MiniSwap) 不够时 approve(MiniSwap, 无限额度)
  W->>U: 第 1 次签名:授权
  SP->>S: 再报价一次,重算 minOut
  SP->>S: swap(tokenIn, tokenOut, amountIn, minOut, deadline)(卖原生币时附带 value)
  W->>U: 第 2 次签名:兑换
  S-->>SP: 回执成功 → 插入"确认中"记录,刷新余额
  BE-->>SP: SSE 推送 swap 事件
  SP->>BE: GET /api/swaps → 真实记录替换"确认中"

7.2 报价:getAmountOut + 防抖 + 防乱序

报价是读操作:调用 MiniSwap 的 getAmountOut(tokenIn, tokenOut, amountIn),合约按恒定乘积公式(含 0.3% 手续费)算出能换多少,前端不自己算,这样前后一致、不会因为公式写错而报错价:

// src/utils/web3/swap.ts(节选)
/** 合约里原生币用 address(0) 表示;前端资产配置里用 0xeeee… 占位,这里转换一下 */
function contractToken(asset: AssetInfo): string {
  return asset.native ? ZeroAddress : asset.address;
}

/** 报价:用 amountIn 能换到多少 assetOut(已扣 0.3% 手续费)。池子不存在时合约会 revert */
export async function quote(
  network: Network,
  swap: string,
  assetIn: AssetInfo,
  assetOut: AssetInfo,
  amountIn: bigint,
): Promise<bigint> {
  if (isTron(network)) {
    const c = getTronWeb().contract(tronSwapAbi, chainAddress(network, swap));
    const args = [
      chainAddress(network, contractToken(assetIn)),
      chainAddress(network, contractToken(assetOut)),
      amountIn.toString(),
    ];
    return toBigInt(await c.getAmountOut(...args).call());
  }
  const c = new Contract(swap, swapAbi, getProvider());
  return c.getAmountOut(
    contractToken(assetIn),
    contractToken(assetOut),
    amountIn,
  ) as Promise<bigint>;
}

合约里的公式(第 2 篇详解):

amountOut = amountIn × 9970 × reserveOut / (reserveIn × 10000 + amountIn × 9970)

用户每敲一个字符都去调 RPC 既浪费又容易"后发先至",所以面板做了两件事:

// src/components/SwapPanel/index.vue(节选)
// ---------- 报价:输入停止 300ms 后请求一次;只采用最后一次请求的结果 ----------
let quoteSeq = 0;
let quoteTimer: ReturnType<typeof setTimeout> | undefined;
async function refreshQuote() {
  const seq = ++quoteSeq;
  quoteError.value = "";
  if (!swap.value || !sell.value || !buy.value || !props.ready) {
    quoted.value = null;
    poolReserves.value = null;
    return;
  }
  quoting.value = true;
  try {
    const [r, q] = await Promise.all([
      reserves(props.network, swap.value, sell.value, buy.value),
      amountIn.value > 0n
        ? quote(
            props.network,
            swap.value,
            sell.value,
            buy.value,
            amountIn.value,
          )
        : Promise.resolve(null),
    ]);
    if (seq !== quoteSeq) return; // 期间又发起了新请求:丢弃这次(过时的)结果
    poolReserves.value = r;
    quoted.value = q;
    if (r[0] === 0n || r[1] === 0n)
      quoteError.value = `${sell.value.symbol}-${buy.value.symbol} 池子还没有流动性`;
  } catch (e) {
    if (seq !== quoteSeq) return;
    quoted.value = null;
    quoteError.value = `报价失败:${explainError(e)}`;
  } finally {
    if (seq === quoteSeq) quoting.value = false;
  }
}
watch(
  [
    () => props.network,
    () => props.ready,
    sellSymbol,
    buySymbol,
    amountIn,
    swap,
  ],
  () => {
    clearTimeout(quoteTimer);
    quoteTimer = setTimeout(refreshQuote, 300); // 防抖(debounce):输入停止 300ms 才请求
  },
  { immediate: true },
);
  • 防抖(debounce):连续输入时不断重置定时器,停下 300ms 才真正请求一次。
  • 序号防乱序:每次请求拿一个递增的 seq,返回时发现 seq !== quoteSeq(后面又发了新请求)就直接丢弃。否则用户从 "5" 改到 "500",慢的 "5" 的报价可能后回来覆盖 "500" 的报价。
  • 储备和报价并发请求(Promise.all):没输入数量时也会读储备,这样"当前价格"始终可见。

7.3 池子储备、当前价格与价格影响

reserves() 读的是 MiniSwap 的 getReserves(a, b),返回顺序与传入的两个资产一致:

// src/utils/web3/swap.ts(节选)
export async function reserves(
  network: Network,
  swap: string,
  a: AssetInfo,
  b: AssetInfo,
): Promise<[bigint, bigint]> {
  if (isTron(network)) {
    const c = getTronWeb().contract(tronSwapAbi, chainAddress(network, swap));
    const r = (await c
      .getReserves(
        chainAddress(network, contractToken(a)),
        chainAddress(network, contractToken(b)),
      )
      .call()) as Record<string, unknown> | unknown[];
    // tronWeb 对多个返回值返回数组(也带有按输出名命名的字段)
    const arr = Array.isArray(r) ? r : [r.reserveA, r.reserveB];
    return [toBigInt(arr[0]), toBigInt(arr[1])];
  }
  const c = new Contract(swap, swapAbi, getProvider());
  const [ra, rb] = (await c.getReserves(
    contractToken(a),
    contractToken(b),
  )) as [bigint, bigint];
  return [ra, rb];
}

当前价格(spot price) 就是两边储备之比,只用于展示,所以允许用浮点数:

// src/components/SwapPanel/index.vue(节选)
/** 当前池子价格:1 个卖出资产 ≈ 多少买入资产(只用于展示,按浮点数计算) */
const spotPrice = computed(() => {
  const r = poolReserves.value;
  if (!r || !sell.value || !buy.value || r[0] === 0n) return "";
  const price =
    Number(fromUnits(r[1], buy.value.decimals)) /
    Number(fromUnits(r[0], sell.value.decimals));
  return `1 ${sell.value.symbol} ≈ ${formatNumber(price)} ${buy.value.symbol}`;
});

注意两边要各用各的精度换算:MTK 18 位、USDT 6 位,直接拿最小单位相除会差 10¹² 倍。

价格影响(price impact):你这笔交易让成交均价比当前价格差了多少。恒定乘积池子里,你卖得越多,价格被推得越偏,所以大单的"平均成交价"一定比当前价格差:

// src/utils/web3/swap.ts
/**
 * 价格影响:这笔交易让成交均价比当前池子价格差了多少(万分比)。
 * 池子越浅、交易越大,影响越大——这就是恒定乘积 AMM 的“滑点”来源(与上面防止价格变动的“滑点保护”是两回事)。
 */
export function priceImpactBps(
  amountIn: bigint,
  amountOut: bigint,
  reserveIn: bigint,
  reserveOut: bigint,
): bigint {
  if (amountIn === 0n || reserveIn === 0n || reserveOut === 0n) return 0n;
  // 不考虑价格变动时按当前价格应得的数量(扣手续费后)
  const ideal =
    (amountIn * reserveOut * (10_000n - SWAP_FEE_BPS)) / (reserveIn * 10_000n);
  if (ideal === 0n) return 0n;
  return ((ideal - amountOut) * 10_000n) / ideal;
}

bps(basis point,基点) = 万分之一,100 bps = 1%。全程用 bigint 整数运算,没有浮点误差。

用真实例子算一遍(Sepolia ETH-MTK 池,初始 0.5 ETH / 5000 MTK,卖 500 MTK):

量计算结果
当前价格0.5 / 50001 MTK = 0.0001 ETH
理想应得 ideal(按当前价、扣 0.3% 费)500 × 0.5 × 0.997 / 50000.04985 ETH
实际报价 amountOut(合约公式)498.5 × 0.5 / (5000 + 498.5)0.045330 ETH
价格影响(0.04985 − 0.04533) / 0.04985≈ 9.067% → bps 向下取整为 906 → 页面显示 9.06%

链上真实成交记录也正是 500 MTK → 0.04533 ETH(Sepolia,11:27:48)。为什么影响这么大?因为 500 MTK 是池子里 MTK 的 10%——池子越浅、单子越大,价格影响越大。

页面按阈值上色:impactBps >= 500n(≥ 5%)显示红色,>= 100n(≥ 1%)显示橙色。

一个更极端的真实例子(Nile TRX-MTK 池,此前已被买过一笔,约 110 TRX / 272.80 MTK):卖 1000 MTK(是池中 MTK 的 3.7 倍)只换到 86.367818 TRX。按当时价格理想应得约 110 × 1000 × 0.997 / 272.80 ≈ 402 TRX,价格影响约 78.5%(按公式估算)。这就是"浅池 + 大单"的代价,真实 DEX 会对这种交易给出强烈警告。

7.4 滑点容忍与 minOut:两个"滑点"别搞混

报价是"现在"算的,但交易要等打包才执行。在这几秒到几十秒里,别人可能先成交,把池子价格推走;你拿到的可能比报价少。所以 swap 要带一个 minAmountOut(最少收到):实际成交少于它,合约整笔回滚。

// src/utils/web3/swap.ts
/** 按滑点计算最少收到的数量:预计收到 × (1 − 滑点)。slippageBps 为万分比,50 = 0.5% */
export function minOut(expected: bigint, slippageBps: bigint): bigint {
  return (expected * (10_000n - slippageBps)) / 10_000n;
}

页面提供 0.5% / 1% / 3% 三档(SLIPPAGE_OPTIONS,值为 50 / 100 / 300 bps),默认 0.5%。上面的例子:预计 0.045330 ETH,0.5% 滑点下"最少收到"≈ 0.045103 ETH。

概念是什么谁决定在代码里
价格影响你自己这笔交易推动价格造成的偏差池子深度 + 交易大小(报价时就确定了)priceImpactBps
滑点容忍从报价到成交期间,别人的交易让价格变动,你最多能接受多少用户选择slippageBps → minOut

滑点设得太小,价格稍有波动交易就失败(失败也扣手续费);设得太大,容易被"三明治攻击"(MEV 机器人在你前后各插一笔,把你的成交价压到下限)。

提交时会重新报价:用户可能看完报价停留了很久,所以 submit 里不直接用界面上的 quoted,而是再调一次 quote 并据此重算 minOut:

// 以最新报价为准重新计算“最少收到”,避免用户停留过久、池子价格已经变了
const expected = await quote(
  props.network,
  swap.value,
  sell.value,
  buy.value,
  amountIn.value,
);
const min = minOut(expected, BigInt(slippageBps.value));

7.5 deadline:交易的"有效期"

// src/utils/web3/swap.ts(节选)
/** 兑换交易的有效期:超过这个时间还没被打包就作废,避免按过时的价格成交 */
const DEADLINE_SECONDS = 20 * 60;

const deadline = BigInt(Math.floor(Date.now() / 1000) + DEADLINE_SECONDS); // 在 executeSwap 里

为什么有了 minOut 还要 deadline?设想 Gas 给得太低,交易在交易池里卡了几个小时才被打包:这期间市场价格可能已经大涨,按几小时前算的 minOut 成交虽然"不亏报价",但相对当时的市价亏了。deadline 让合约在 block.timestamp > deadline 时直接拒绝。

注意 deadline 用的是浏览器本地时间,合约比较的是区块时间。用户电脑时间严重不准时可能出问题(这也是 Uniswap 等前端的通行做法,20 分钟通常足够宽松)。

7.6 原生币 vs 代币:approve / allowance 与无限授权

卖原生币(ETH / TRX):钱随交易一起"附带"过去——EVM 用 msg.value,TRON 用 callValue,合约收到就能用,不需要授权。

卖代币(USDT / MTK):代币存在代币合约的账本里,MiniSwap 要用 transferFrom 从你账上扣款,必须先获得你的许可。这就是 ERC-20 的"授权 + 代扣"模式:

① approve(MiniSwap, 额度)       ← 你签名:允许 MiniSwap 最多扣我这么多 MTK
② swap(...)                      ← 你签名:MiniSwap 在里面调用 MTK.transferFrom(你, MiniSwap, amountIn)

前端先查当前额度,不够才需要授权:

// src/utils/web3/swap.ts(节选)
/** 代币已授权给兑换合约的额度;原生币不需要授权,返回最大值 */
export async function allowance(
  network: Network,
  asset: AssetInfo,
  owner: string,
  swap: string,
): Promise<bigint> {
  if (asset.native) return MaxUint256;
  if (isTron(network)) {
    const c = getTronWeb().contract(
      tronTokenAbi,
      chainAddress(network, asset.address),
    );
    return toBigInt(
      await c.allowance(owner, chainAddress(network, swap)).call(),
    );
  }
  const c = new Contract(asset.address, tokenAbi, getProvider());
  return c.allowance(owner, swap) as Promise<bigint>;
}

/**
 * 授权兑换合约使用代币。授权为无限额度:之后再卖同一种代币不用每次都授权,省一笔交易;
 * 代价是如果兑换合约有漏洞,授权过的代币有风险——真实项目里常让用户选择“只授权本次金额”。
 */
export async function approve(
  network: Network,
  asset: AssetInfo,
  swap: string,
  from: string,
): Promise<SentTransfer> {
  if (isTron(network)) {
    const txId = await getTronWeb()
      .contract(tronTokenAbi, chainAddress(network, asset.address))
      .approve(chainAddress(network, swap), MaxUint256.toString())
      .send({ feeLimit: TRON_FEE_LIMIT });
    return { hash: "0x" + txId, wait: () => waitTronTx(txId) };
  }
  const c = new Contract(asset.address, tokenAbi, await getSigner(from));
  const tx = await c.approve(swap, MaxUint256);
  return evmSent(tx);
}
// src/components/SwapPanel/index.vue
const needApprove = computed(
  () =>
    !!sell.value &&
    !sell.value.native &&
    amountIn.value > 0n &&
    allowed.value < amountIn.value,
);

无限授权(MaxUint256 = 2²⁵⁶−1)的取舍:

无限授权(本项目)只授权本次金额
体验同一种代币只需授权一次,以后每次兑换只签一次每次兑换都要先授权,多一次签名、多一笔手续费
风险兑换合约有漏洞或被升级成恶意版本时,你钱包里全部该代币都可能被转走最多损失本次授权的金额
适用自己写的学习项目、高度信任的头部协议陌生合约、大额资产

学习建议:在 Etherscan 的 Token Approval Checker 等工具里定期查看、撤销不再使用的授权(撤销就是 approve(spender, 0))。更现代的方案还有 EIP-2612 permit(离线签名授权,省掉一笔 approve 交易)——本项目没有实现,仅供延伸。

7.7 "授权并兑换":一次点击,两次签名

早期版本里,需要授权时按钮只写"授权",用户点完、授权上链,以为兑换完成了,其实只完成了第一步。现在按钮状态机会直接写明"授权 MTK 并兑换",一次点击串起两笔交易:

// src/components/SwapPanel/index.vue(节选)
const action = computed<{ text: string; disabled: boolean }>(() => {
  if (!props.ready) return { text: "请先连接钱包", disabled: true };
  if (!swap.value) return { text: "当前网络未部署兑换合约", disabled: true };
  if (amountIn.value === 0n) return { text: "输入卖出数量", disabled: true };
  if (insufficient.value)
    return { text: `${sell.value?.symbol} 余额不足`, disabled: true };
  if (quoteError.value || quoted.value === null)
    return { text: quoting.value ? "报价中…" : "暂时无法兑换", disabled: true };
  if (needApprove.value)
    return { text: `授权 ${sell.value?.symbol} 并兑换`, disabled: false };
  return { text: "兑换", disabled: false };
});

async function submit() {
  if (!swap.value || !sell.value || !buy.value) return;
  submitting.value = true;
  try {
    if (needApprove.value) {
      const tx = await approve(
        props.network,
        sell.value,
        swap.value,
        props.account,
      ); // 第 1 次签名
      MessagePlugin.info(`授权交易已广播,等待打包…`);
      await tx.wait(); // 必须等授权上链:否则 swap 里的 transferFrom 会因额度不足失败
      await refreshAllowance();
      // 授权成功后直接接着发起兑换:否则用户点一次按钮只完成了授权,容易误以为“兑换没成功”
      MessagePlugin.info(
        `已授权兑换合约使用 ${sell.value.symbol},请在钱包中确认兑换交易`,
      );
    }
    const expected = await quote(
      props.network,
      swap.value,
      sell.value,
      buy.value,
      amountIn.value,
    );
    const min = minOut(expected, BigInt(slippageBps.value));
    const tx = await executeSwap(
      props.network,
      swap.value,
      sell.value,
      buy.value,
      amountIn.value,
      min,
      props.account,
    ); // 第 2 次签名
    lastHash.value = tx.hash;
    MessagePlugin.info("兑换交易已广播,等待打包…");
    await tx.wait();
    addConfirming(
      tx.hash,
      sell.value.address,
      buy.value.address,
      amountIn.value,
      expected,
    ); // 7.10 节
    MessagePlugin.success(
      `兑换成功:-${show(amountIn.value, sell.value)} ${sell.value.symbol},预计 +${show(expected, buy.value)} ${buy.value.symbol}`,
    );
    amountText.value = "";
    emit("done"); // 首页刷新余额
    void refreshQuote();
    void refreshAllowance();
  } catch (e) {
    MessagePlugin.error(explainError(e));
  } finally {
    submitting.value = false;
  }
}

要点:

  • 两笔交易、两次签名、两份手续费,这是 ERC-20 授权模型决定的,前端只能把体验串起来,无法合并成一笔(除非用 permit 或智能账户批量交易)。
  • 必须等授权上链后再发兑换。如果授权还在交易池里就发 swap,swap 执行时额度还是 0,会 revert。
  • 用户在第 2 次签名时点了拒绝:授权已经生效(无限额度),下次再点按钮会直接显示"兑换",只需签一次。
  • 首页通过 @done="refreshAll" 在兑换成功后刷新余额和交易列表。
  • "最大"按钮会给原生币留手续费:NATIVE_FEE_RESERVE = { ETH: '0.005', TRX: '30' },卖代币时则可以卖光。

7.8 EVM(ethers)与 TRON(tronWeb)的调用差异

同一个 executeSwap,两条链的写法差异集中体现了前两章讲的 TRON 特点:

// src/utils/web3/swap.ts(节选)
export async function executeSwap(
  network,
  swap,
  assetIn,
  assetOut,
  amountIn,
  minAmountOut,
  from,
): Promise<SentTransfer> {
  const deadline = BigInt(Math.floor(Date.now() / 1000) + DEADLINE_SECONDS);
  const value = assetIn.native ? amountIn : 0n;
  if (isTron(network)) {
    // TronLink 只能用插件里当前选中的账户签名
    if (getTronWeb().defaultAddress.base58 !== from)
      throw new Error(
        "TronLink 当前账户与页面连接的账户不一致,请在 TronLink 中切换回来",
      );
    if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("金额过大");
    const txId = await getTronWeb()
      .contract(tronSwapAbi, chainAddress(network, swap))
      .swap(
        chainAddress(network, contractToken(assetIn)),
        chainAddress(network, contractToken(assetOut)),
        amountIn.toString(),
        minAmountOut.toString(),
        deadline.toString(),
      )
      // 合约调用会消耗能量(不足时燃烧 TRX),兑换比普通转账更复杂,给更高的上限
      .send({ feeLimit: TRON_FEE_LIMIT * 2, callValue: Number(value) });
    return { hash: "0x" + txId, wait: () => waitTronTx(txId) };
  }
  const c = new Contract(swap, swapAbi, await getSigner(from));
  const tx = await c.swap(
    contractToken(assetIn),
    contractToken(assetOut),
    amountIn,
    minAmountOut,
    deadline,
    { value },
  );
  return evmSent(tx);
}
差异点EVM(ethers)TRON(tronWeb)
地址参数0x 地址直接传先用 chainAddress → hexToTron 转成 T 开头(原生币 address(0) → T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb)
数值参数直接传 bigint转成十进制字符串 amountIn.toString()(避免 number 丢精度)
附带原生币最后一个参数 overrides:{ value }.send({ callValue: Number(value) }),单位 sun;超过 MAX_SAFE_INTEGER 直接报错
手续费上限ethers 自动 eth_estimateGas手动给 feeLimit:转账 / 授权 100 TRX,兑换 200 TRX(TRON_FEE_LIMIT * 2)
签名账户getSigner(from) 指定只能用 TronLink 当前账户,先比对 defaultAddress.base58
返回值ContractTransactionResponse(含 hash、wait())只有交易 ID 字符串,前端补 0x 并用 waitTronTx 轮询
读合约返回值bigint / 元组可能是 bigint、BigNumber 或数组,统一 BigInt(String(v))
原生币精度ETH 18 位TRX 6 位(10 TRX = 10,000,000 sun)

两边最后都包装成同一个 SentTransfer { hash, wait() },所以 SwapPanel 里完全没有 if (tron) 分支。

合约侧还有一个 TRON 特有的坑:Nile 上的 USDT transfer 转账成功却返回 false,所以 MiniSwap 用"转账前后余额差"判断到账,详见第 2 篇。

7.9 等待交易与错误提示

兑换和转账共用 wait() 的语义:成功返回区块号,链上执行失败抛错。兑换特有的失败原因主要来自合约的 revert(名称以合约源码为准,见第 2 篇):

场景发生在哪用户看到
池子没有流动性报价时黄色提示"USDT-MTK 池子还没有流动性",按钮"暂时无法兑换"
报价调用失败(例如合约不存在)报价时"报价失败:……"(经 explainError 翻译)
余额不足点击前按钮直接显示"MTK 余额不足"并禁用
用户拒绝签名(授权或兑换)签名时"你在钱包中拒绝了签名"
实际成交低于 minOut / 超过 deadline链上执行EVM:"合约执行失败:<错误名>";TRON:"交易执行失败:REVERT"
TRX 不足以支付能量TRON 广播 / 执行"TRX 不足以支付手续费(能量 / 带宽)" 或 "能量不足且 feeLimit 不够燃烧 TRX 抵扣"

所有错误都在 submit 的 catch 里统一交给 explainError(第九章)。

7.10 "确认中"乐观记录与 SSE 替换

交易上链后,后端要等够确认数(源码注释:Sepolia 2 块 ≈ 30 秒,Nile 19 块 ≈ 1 分钟)才把 Swap 事件入库。这段时间如果"我的兑换记录"里什么都没有,用户会怀疑兑换没成功。于是面板做了乐观更新(optimistic update):先把本地已知的信息显示出来,标上"确认中"。

// src/components/SwapPanel/index.vue(节选)
type DisplayRecord = SwapRecord & { confirming?: boolean };
const confirming = ref<DisplayRecord[]>([]);

function addConfirming(
  txHash: string,
  tokenIn: string,
  tokenOut: string,
  amountIn: bigint,
  expectedOut: bigint,
) {
  confirming.value.unshift({
    id: -Date.now(), // 负数 id,不会与后端记录冲突
    network: props.network,
    contract: swap.value ?? "",
    txHash: txHash.toLowerCase(),
    blockNumber: 0,
    blockTime: new Date().toISOString(),
    user: props.apiAccount,
    tokenIn: tokenIn.toLowerCase(),
    tokenOut: tokenOut.toLowerCase(), // 原生币就是 0xeeee…,与后端一致
    amountIn: amountIn.toString(),
    amountOut: expectedOut.toString(), // 买入数量用报价估算,页面显示 "≈+…"
    confirming: true,
  });
}

const displayRecords = computed<DisplayRecord[]>(() => {
  const synced = new Set(records.value.map((r) => r.txHash.toLowerCase()));
  const waiting = confirming.value.filter(
    (r) =>
      r.network === props.network &&
      r.user === props.apiAccount &&
      !synced.has(r.txHash),
  );
  return [...waiting, ...records.value];
});

// 后端已入库的记录不再需要占位
watch(records, (list) => {
  const synced = new Set(list.map((r) => r.txHash.toLowerCase()));
  confirming.value = confirming.value.filter((r) => !synced.has(r.txHash));
});

useBackendEvents({
  ready: loadRecords,
  swap: (e) =>
    e.network === props.network &&
    e.user === props.apiAccount &&
    void loadRecords(),
});

流程:

  1. 兑换上链 → addConfirming 插入一条"确认中"记录,买入数量前面带 ≈(因为是报价估算,实际成交可能略有不同)。
  2. 后端入库后通过 SSE 推送 swap 事件(带 network、user)→ 与自己相关就 loadRecords() 拉 /api/swaps。
  3. 真实记录里出现同一个 txHash → "确认中"那条被过滤掉,真实成交数量替换估算值。

用 txHash 做去重键、把 user / network 纳入过滤,是为了切换账户或网络时不把别人的"确认中"记录显示出来。

7.11 小结

环节关键点
报价调合约 getAmountOut,不在前端复刻公式;300ms 防抖 + 序号丢弃过时结果
价格当前价格 = 储备比(浮点,仅展示);价格影响 = (理想 − 实得) / 理想(bigint,万分比)
保护minOut 防"别人把价格推走",deadline 防"交易卡太久";提交前重新报价
授权卖代币先 approve(无限额度,省事但有风险),卖原生币附带 value / callValue
两条链同一套 SentTransfer 接口;TRON 要转 Base58、传字符串、给 feeLimit、校验当前账户
体验"授权并兑换"一次点击;"确认中"乐观记录由 SSE 推送后替换为真实记录

八、与后端交互:REST 请求与 SSE 推送

这一章解决的问题:前端怎样统一地调用后端的 REST 接口,以及怎样用 SSE 替代轮询(这部分与 Web2 前端基本相同)。

8.1 统一请求封装

后端所有 /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 只有在网络层失败(后端没启动、跨域被拒)时才会抛错,浏览器给的信息只有 "Failed to fetch"
    throw new Error(
      `无法连接后端 ${BASE || location.origin},请先启动后端(make backend)`,
    );
  }
  // 响应体不是 JSON(例如 nginx 返回的 502 页面)时给一个空对象,交给下面的 code 判断统一报错
  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/swap.ts(节选)
export function listSwaps(
  address: string,
  network: Network,
  limit = 20,
): Promise<SwapRecord[]> {
  const q = new URLSearchParams({ address, network, limit: String(limit) });
  return http<SwapRecord[]>(`/api/swaps?${q}`);
}
接口用途调用时机
POST /api/users登记用户(带 network 时登记到该链),拿昵称连接钱包、查询对方
GET /api/users/counterparties"对方地址"下拉框的候选用户切换网络 / 账户、有新交易入库时
GET /api/transactions交易记录(按地址、网络、合约过滤)收到 tx 推送时;推送不可用时 15 秒轮询;转账后 10 秒兜底查询
GET /api/swaps我的兑换记录收到 swap 推送时;切换账户 / 网络时
GET /api/networks各链各资产的同步进度收到 ready 时拉一次,之后按 progress 推送增量更新;推送不可用时 15 秒轮询
GET /api/eventsSSE 推送通道页面加载时建立,全页共用一条

所有金额字段(amount、amountIn、amountOut)都是字符串,地址都是小写 0x(TRON 也是),前端展示时再转换。

后端地址由 .env.local 的 VITE_API_BASE 决定:开发环境是 http://localhost:8080;生产环境留空,请求同源的 /api,由 nginx 转发给后端。

8.2 从轮询到 SSE 推送

旧版本里,同步进度、两个交易列表、"等待入库"各自定时请求后端,大部分请求拿回来的都是没变化的数据。现在改成后端在数据真的变化时推送,页面只在收到事件时才去拉数据。

SSE(Server-Sent Events,服务器推送事件) 是浏览器原生支持的单向推送:前端 new EventSource(url) 建立一条长连接,服务器按 event: 类型 \n data: JSON 的文本格式往下推。和 WebSocket 比,它只能服务器 → 浏览器单向推送,但基于普通 HTTP、浏览器自动重连、不需要额外的库,非常适合"通知前端该刷新了"这种场景。

// src/hooks/useBackendEvents.ts(节选)
const connected = ref(false);
const handlers = new Set<BackendEventHandlers>();
let source: EventSource | undefined;

function connect() {
  if (source || typeof EventSource === "undefined") return; // 全页只建一条连接
  source = new EventSource(`${BASE}/api/events`);
  source.addEventListener("ready", () => {
    connected.value = true;
    emit("ready");
  });
  source.addEventListener("progress", (e) =>
    emit("progress", JSON.parse((e as MessageEvent).data)),
  );
  source.addEventListener("tx", (e) =>
    emit("tx", JSON.parse((e as MessageEvent).data)),
  );
  source.addEventListener("swap", (e) =>
    emit("swap", JSON.parse((e as MessageEvent).data)),
  );
  source.onerror = () => {
    if (connected.value) {
      connected.value = false;
      emit("disconnect");
    }
    // 网络中断时 EventSource 会自己重连;但收到非 200 响应(如旧版后端 404)时它会彻底关闭,需要手动重连
    if (source?.readyState === EventSource.CLOSED) {
      source = undefined;
      clearTimeout(reconnectTimer);
      reconnectTimer = setTimeout(connect, RECONNECT_DELAY); // 5 秒后重连
    }
  };
}
事件数据谁在听收到后做什么
ready—所有订阅者连上(或重连)时拉一次完整数据作为基准
progressnetwork, contract, lastBlock, latestBlockuseSyncStatus原地更新"我的钱包"卡片里的同步进度
txnetwork, contract, txHash, from, toTxList、首页的 waitIndexed、对方下拉框与本列表地址有关才重新拉 /api/transactions
swapnetwork, txHash, user, tokenIn, tokenOutSwapPanel与当前账户有关才重新拉 /api/swaps

设计原则:"推送只是提醒该刷新了",数据本身仍然通过 REST 拉取。这样推送丢一条也不会导致数据错误,下次拉取就补齐了。

推送不可用时退回低频轮询:后端没启动、旧版后端没有 /api/events 时,connected 为 false,各订阅者用 usePolling 接管:

// src/hooks/usePolling.ts(节选)
export function usePolling(fn: () => unknown, intervalMs: number, enabled: () => boolean = () => true) {
  const visible = () => document.visibilityState === 'visible'
  function tick() { if (enabled() && visible()) void fn() }       // 推送可用或页面在后台时不请求
  // 切回前台时无论推送是否可用都刷新一次:后台标签页里浏览器可能延迟处理事件
  function onVisibilityChange() { if (visible()) void fn() }
  ...
}

// 用法:TxList / useSyncStatus
usePolling(load, 15000, () => !connected.value)

useSyncStatus 还用"推送通道可用 或 最近一次请求成功"来判断后端是否在线,推送一断开就立即请求一次 /api/networks,"后端未启动"的提示才能及时出现。


九、错误处理:把英文报错翻译成人话

Web3 的错误来源比 Web2 多得多:钱包、节点、合约都可能报错,而且都是英文;两类钱包的报错格式还不一样——ethers 有规范的错误码,TronLink / tronWeb 的报错大多是纯字符串。这一章看前端怎样统一翻译。utils/web3/error.ts:

// src/utils/web3/error.ts(节选)
function explainTronError(msg: string): string | undefined {
  if (/declined|reject|cancel/i.test(msg)) return "你在钱包中拒绝了签名";
  if (/balance is not sufficient/i.test(msg))
    return "TRX 不足以支付手续费(能量 / 带宽)";
  if (/REVERT/.test(msg)) return "合约拒绝:通常是 USDT 余额不足";
  if (/OUT_OF_ENERGY/.test(msg)) return "能量不足且 feeLimit 不够燃烧 TRX 抵扣";
  return undefined;
}

export function explainError(e: unknown): string {
  if (typeof e === "string") return explainTronError(e) ?? e;
  if (isError(e, "ACTION_REJECTED")) return "你在钱包中拒绝了签名";
  if (isError(e, "CALL_EXCEPTION")) {
    // 合约 revert 时带回 custom error,ethers 会用 ABI 解码出错误名
    if (e.revert?.name === "ERC20InsufficientBalance")
      return "合约拒绝:MTK 余额不足(ERC20InsufficientBalance)";
    // missing revert data:调用没有返回任何数据,最常见的原因是该地址上根本没有合约代码
    if (!e.revert && !e.data) return NO_CONTRACT_HINT;
    return `合约执行失败:${e.revert?.name ?? e.shortMessage}`;
  }
  // 对没有代码的地址做 eth_call,节点返回 "0x",ethers 解码失败会报 BAD_DATA,原因同上
  if (isError(e, "BAD_DATA") && e.value === "0x") return NO_CONTRACT_HINT;
  if (isError(e, "INSUFFICIENT_FUNDS"))
    return "ETH 余额不足以支付转账金额和 Gas 费";
  const err = e as { shortMessage?: string; message?: string };
  const msg = err.shortMessage ?? err.message ?? String(e);
  return explainTronError(msg) ?? msg;
}
场景错误来源页面提示
用户在钱包里点了"拒绝"ethers ACTION_REJECTED / TronLink "Confirmation declined by user" 等你在钱包中拒绝了签名
转账金额超过余额(合约 revert)CALL_EXCEPTION + ERC20InsufficientBalance合约拒绝:MTK 余额不足
其他合约 revert(如兑换滑点超限)CALL_EXCEPTION + 解码出的错误名合约执行失败:<错误名>
anvil 没启动 / 合约没部署CALL_EXCEPTION 无数据,或 BAD_DATA链上没有返回数据:请确认……
账户 ETH 不够付金额 + GasINSUFFICIENT_FUNDSETH 余额不足以支付转账金额和 Gas 费
TRX 不够付能量 / 带宽TronLink 字符串 "balance is not sufficient"TRX 不足以支付手续费(能量 / 带宽)
TRON 合约执行失败REVERT / OUT_OF_ENERGY合约拒绝…… / 能量不足且 feeLimit 不够……

能解码出 ERC20InsufficientBalance 这个名字,是因为合约使用了 OpenZeppelin v5 的 custom error(自定义错误),revert 数据里带着错误选择器,ethers 再用 ABI 反查出错误名。这也是前端需要完整 ABI 的原因之一。解码过程见第 2 篇「ABI 的两大作用:编码与解码」一节。

TRON 的 REVERT 统一提示"通常是 USDT 余额不足",对兑换场景(例如滑点超限)并不准确——这是按关键字匹配的局限,需要更精确时可以解析 getTransactionInfo 返回的 resMessage。


十、动手 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 来自 EIP-6963)
provider.getSigner()getSigner(account)(指定账户签名)
new Contract(TOKEN, ABI, provider).balanceOf()getAssetBalance()
new Contract(TOKEN, ABI, signer).transfer()sendAsset()
parseUnits / formatUnits(写死 18 位)toUnits / fromUnits(按资产精度)
tx.wait()等待打包上链(TRON 为 waitTronTx)

项目代码比 Demo 多出来的部分,正是一个"能用"的 DApp(去中心化应用)必须处理的细节:多钱包发现、多链与地址格式、按网络的账户会话、网络校验与切换、表单校验、错误翻译、钱包事件、刷新后恢复连接、与后端配合展示昵称和历史记录。

用 Demo 转账后,如果后端在运行,项目页面的交易列表里同样会出现这笔记录:后端只监听链上事件,不关心交易是从哪个页面发起的。

想继续练手:给 Demo 加一个"兑换"按钮,先 approve(MiniSwap, amount)、await tx.wait(),再调 swap(MTK, address(0), amount, 0, deadline)(minAmountOut 传 0 仅用于练习,真实场景必须按滑点计算)。


十一、常见坑

下面这些坑都能在本项目源码里找到对应的处理(或值得注意的地方),新手自己写 DApp 时几乎都会遇到。

1. 切换账户 / 网络后残留上一个的状态

余额、对方信息、进度条、"最近一笔"如果不清空,就会出现"A 账户页面上显示着 B 账户的交易"。项目里的处理:

  • 切网络、选账户时调用 resetChainState(),清空 peer、lastTx、手动输入的对方地址和表单。
  • SwapPanel 监听 props.account,切换账户时清掉 lastHash(源码注释:"避免把别人的交易当成自己的");"确认中"记录按 user === apiAccount && network === 当前网络 过滤。
  • 异步请求返回前可能已经切了网络:loadCounterparties 里 if (n === network.value) 才采用结果,报价用 seq 丢弃过时结果。
  • waitIndexed 在 lastTx.value?.hash !== hash 时不再更新进度条,防止旧交易的"已入库"盖到新交易上。

2. 授权成功,以为已经兑换了

approve 本身也是一笔交易,钱包弹窗、上链、成功提示一样不少,用户很容易以为"兑换完成了"。项目的处理是按钮文案写明"授权 MTK 并兑换"、授权上链后自动发起兑换,并提示"请在钱包中确认兑换交易"。反过来也要知道:用户在第二次签名时拒绝,授权依然有效。

3. bigint 精度与单位

  • 金额全程 bigint;只有纯展示(当前价格 spotPrice)才转 number。
  • 每种资产用自己的精度:USDT 6 位、MTK 18 位,用错精度会差 10¹² 倍。toUnits('0.1234567', 6) 会抛错,所以校验用 tryToUnits。
  • bigint 除法向下取整:minOut、priceImpactBps 都是先乘后除,尽量保留精度(这也是页面显示 9.06% 而不是 9.07% 的原因)。
  • tronWeb 的部分接口只收 number(TRX 转账金额、callValue),项目在转换前检查 > Number.MAX_SAFE_INTEGER 就报"金额过大";其余数值一律 toString() 传字符串。
  • tronWeb 读合约的返回值类型不稳定(bigint / BigNumber / 数组),统一 BigInt(String(v))。

4. TronLink 当前账户与页面连接的账户不一致

TronLink 只能用插件里当前选中的账户签名。项目在 sendAsset 和 executeSwap 里都先比对 getTronWeb().defaultAddress.base58 !== from,不一致就报"TronLink 当前账户与页面连接的账户不一致,请在 TronLink 中切换回来"。另外 onAccountsChanged 会让 Nile 的会话跟随 TronLink 切换,大多数情况下两者会自动对齐。

已修复:早期版本 swap.ts 的 approve() 在 TRON 分支没有做这项账户比对(from 只用于 EVM 分支),账户不一致时授权会先记在 TronLink 当前账户上,直到兑换那一步才报错。现在 approve() 与 sendAsset、executeSwap 一样,在发交易前先比对账户。

5. 地址大小写与格式

  • EVM 地址大小写只是 EIP-55 校验和,比较前要统一小写;TRON Base58 区分大小写,不能转小写(sameAddress 按链区分)。
  • 后端存小写 0x,TRON 页面显示 T 开头:拿后端数据去和钱包地址比较时,别忘了先用 toApiAddress 统一形式。
  • TRON 交易 ID 不带 0x,项目统一补上以便与后端对齐,生成 TronScan 链接时再去掉。

6. 把"已广播"当成"已成功"

拿到 hash 只是交易进入了交易池;wait() 成功才算上链成功;列表里出现还要再等后端的确认数。三个阶段对应进度条的三步,不要在第一步就提示"转账成功"。

7. 多个钱包抢 window.ethereum

同时装了 MetaMask、OKX、TronLink 时,window.ethereum 是谁不确定。项目用 EIP-6963 按 rdns 选钱包,并把 TronLink 的以太坊模式从 EVM 钱包列表里排除,避免"弹窗里出现两个 TronLink"或被自动选成以太坊钱包。


十二、常见问题

Q:前端能拿到用户的私钥吗? 不能,也不应该。私钥始终在钱包里。前端只能请求签名,签不签由用户在弹窗里决定。

Q:为什么查余额、报价不直接调后端? 链是权威数据源。后端数据有几十秒的延迟(要等确认),而且理论上可能出错。余额、报价这种关键数据直接从链上读最可靠,而且 eth_call 本身就是免费的。

Q:为什么转账、兑换不经过后端? 如果经过后端,后端就要持有用户私钥,这违背了 Web3"自己掌控资产"的原则,也会成为安全隐患。交易必须由用户在自己的钱包里签名。

Q:wait() 返回了,为什么列表里还没有? 上链 ≠ 入库。后端要等确认数(Sepolia 2 个块、Nile 19 个块)后才写入数据库;原生币转账还要等后端扫块。入库后后端会通过 SSE 推送,列表随即刷新;兑换记录在此之前会先显示"确认中"。

Q:为什么兑换实际收到的和报价不完全一样? 报价是在"现在"的池子状态上算的,成交时池子可能已被别人的交易改变。只要不低于"最少收到",交易就会成功;低于它,合约整笔回滚。

Q:价格影响很大是 bug 吗? 不是。恒定乘积池子里,交易量相对池子越大,价格影响越大。本项目的测试池子流动性很浅(例如 ETH-MTK 池只有 0.5 ETH / 5000 MTK),500 MTK 就有 9.06% 的影响。

Q:用户切换了 MetaMask 账户,页面会怎样? 各网络用的是自己选定的账户,MetaMask 里切换"当前选中账户"不影响页面;要换账户请用页面上的"切换账户(仅本网络)"。TronLink 不同:Nile 的账户会跟随插件切换。切换网络时页面会跟随(chainChanged)。

Q:页面上为什么看不到代币图标? ERC-20 标准里没有图标字段,链上只有 name / symbol / decimals。钱包、区块浏览器里的图标来自链下:钱包的 wallet_watchAsset(EIP-747,添加代币时传 image URL)、区块浏览器提交的代币信息、Token List 等。本项目目前没有实现 wallet_watchAsset,钱包弹窗里的钱包图标则来自 EIP-6963 宣告。

Q:src/abi/ 下的文件可以手改吗? 不要手改。它们由部署脚本在部署后自动生成(EVM 链由 contracts/export-abi.sh,MiniSwap 由 export-swap.sh / make deploy-swap-nile 等写入)。重新部署合约后,前端会自动用上新的地址和 ABI。


下一篇预告

到这里,一笔转账或兑换已经由用户在钱包里签名、广播,并被打包上链;前端的进度条停在了"打包上链",兑换记录里挂着一条"确认中",等待后端推送。

那么,交易上链之后,后端是怎样发现它、并把它可靠地记录进数据库、再推送给前端的? 第 4 篇 后端详解:监听链上事件并同步到数据库 将讲解:JSON-RPC 与 WebSocket 两种通信方式、如何订阅 Transfer / Swap 事件、没有事件的原生币转账怎样逐块扫描、为什么要等确认数(区块重组)、如何做到重复处理也不出错(幂等)、RPC 限速,以及 SSE 推送是怎样实现的。