系列导航
| 顺序 | 文章 | 定位 |
|---|---|---|
| 1 | 零代码建立整体认识,智能合约在项目中的意义 | |
| 2 | 代币合约、兑换合约(AMM / 恒定乘积)、ABI、测试、部署 | |
| 3 | 📍当前:前端:多链钱包、转账与链上兑换 | RPC、钱包(EIP-6963 / TronLink)、ethers / tronWeb、转账 12 步、★ 兑换面板 |
| 4 | 事件订阅、确认数、幂等、原生币扫块、SSE 推送 |
读完本篇你将学会
- 前端是怎样和区块链"说话"的:RPC、JSON-RPC、钱包、ethers / tronWeb 各自扮演什么角色。
- 用
new Contract(地址, ABI, runner)把链上合约变成一个普通的 JS 对象,并分清"读"和"写"。 - 一个页面怎样同时支持三条链(本地链 anvil、Sepolia、TRON Nile)和两类钱包(MetaMask 等 EVM 钱包、TronLink)。
- 一笔转账(原生币 / USDT / MTK)在前端从"连接钱包"到"列表出现记录"的完整 12 个步骤。
- ★ 一次链上兑换在前端要处理的全部细节:报价、价格影响、滑点、deadline、授权、两条链的调用差异、乐观记录。
- 如何把 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) | 31337 | MetaMask 等 EVM 钱包 | MTK | 未部署(本地只演示转账) |
| Sepolia 测试网 | 11155111 | MetaMask 等 EVM 钱包 | ETH(原生币)、USDT(6 位小数)、MTK | 0x6569…eafb |
| TRON Nile 测试网 | 3448148188 | TronLink | TRX(原生币,6 位小数)、USDT(6 位)、MTK | TT79caqYHYGSXnRBHMeFzaw6hwoNscSiTb |
本地链的
NETWORKS.anvil没有配置native字段(源码注释:"只有需要展示余额和转账的网络才配置"),所以页面上本地链只显示 MTK。
目录
- 定位:前端在 Web3 中扮演什么角色
- 技术栈与目录结构
- 前端如何与链通信:RPC、钱包、ethers 与 tronWeb
- 用 ABI 调用合约:new Contract
- 多链与多钱包:网络配置、钱包发现与会话
- ★ 链上转账:前端的 12 个步骤
- ★ 链上兑换:SwapPanel 的实现
- 与后端交互:REST 请求与 SSE 推送
- 错误处理:把英文报错翻译成人话
- 动手 Demo:30 行 HTML 完成一次链上转账
- 常见坑
- 常见问题
- 下一篇预告
一、定位:前端在 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 + TypeScript | 3.5 / 6.0 | 页面框架(<script setup> 组合式 API) |
| TDesign Vue Next | 1.20 | UI 组件库(表单、表格、步骤条、弹窗) |
| ethers | 6.17 | EVM 链交互的核心库:连接钱包、编码调用、解码结果;也用它做 TRON 地址的 Base58 编解码 |
| tronWeb | 由 TronLink 注入 | TRON 链交互:项目没有安装 tronweb 包,直接用 TronLink 注入的实例(类型声明见 types/global.d.ts) |
| Vite | 8.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?
- 操作本身就是"调用函数":
eth_call、eth_sendRawTransaction都是"执行某个动作",而不是"增删改查某个资源",用"方法名 + 参数"表达最自然。 - 与传输方式无关:同一套方法可以跑在 HTTP(一问一答)、WebSocket(能收到推送,后端的
eth_subscribe依赖它)、IPC(本机进程间通信)上。 - 全网统一标准:geth、anvil、Infura、Alchemy 都实现同一份规范,所以 ethers、MetaMask、forge 能对接任意节点,换节点只需换一个 URL。
- 批量请求:一次网络往返查多个数据,协议原生支持。
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、请求签名、广播交易好几步)。
3.5 TRON:TronLink 与 tronWeb
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(钱包 / 页面) | 是什么 |
|---|---|---|
0xeca9bc828a3005b9a3b909f2cc5c2a54794de05f | TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf | Nile USDT |
0xd666326b58b01c0902e9b70360ca22dede5411b1 | TVWr2sxGD42Wb5pseEdxEHVt83QzyqLCeJ | Nile MTK |
0xbbfaa366406e2fe989c46828175b92c2388416d3 | TT79caqYHYGSXnRBHMeFzaw6hwoNscSiTb | Nile MiniSwap |
0x0000000000000000000000000000000000000000 | T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb | 零地址(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 标记的)。
5.4 TRON 钱包:TronLink
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 在能力上的几处差异,源码里都做了处理:
| 能力 | MetaMask | TronLink | 项目怎么处理 |
|---|---|---|---|
| 同时授权多个账户 | ✅ eth_accounts 返回全部 | ❌ 只暴露当前账户 tronWeb.defaultAddress.base58 | Nile 的连接账户跟随 TronLink 切换 |
| 用指定账户签名 | ✅ getSigner(account) | ❌ 只能用插件里当前选中的账户 | 发交易前比对,不一致直接报错(第十一章) |
| 撤销授权 | ✅ wallet_revokePermissions | ❌ 不支持 | 页面在 localStorage 记"已断开",刷新后不自动恢复 |
| 查询 chainId | eth_chainId | 新版支持;旧版不支持 | 兜底:按 tronWeb.fullNode.host 是否含 nile 判断 |
5.5 钱包选择弹窗与按网络的会话
WalletModal(仿 RainbowKit)分两步:
- 选钱包:只列出当前网络可用的钱包——本地链 / Sepolia 列 EIP-6963 发现的 EVM 钱包,Nile 列 TronLink。每个钱包显示四级状态:未安装(给下载链接)→ 已安装 → 已授权 → 已连接。
- 选账户:钱包授权了多个账户时(例如 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) | |
|---|---|---|
| EVM | eth_getBalance:读账户本身的余额 | eth_call → balanceOf:读合约账本里的记录 |
| TRON | getaccount:读账户的 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:合约 revert | result === '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 / 5000 | 1 MTK = 0.0001 ETH |
理想应得 ideal(按当前价、扣 0.3% 费) | 500 × 0.5 × 0.997 / 5000 | 0.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-2612permit(离线签名授权,省掉一笔 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(),
});
流程:
- 兑换上链 →
addConfirming插入一条"确认中"记录,买入数量前面带≈(因为是报价估算,实际成交可能略有不同)。 - 后端入库后通过 SSE 推送
swap事件(带network、user)→ 与自己相关就loadRecords()拉/api/swaps。 - 真实记录里出现同一个
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/events | SSE 推送通道 | 页面加载时建立,全页共用一条 |
所有金额字段(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 | — | 所有订阅者 | 连上(或重连)时拉一次完整数据作为基准 |
progress | network, contract, lastBlock, latestBlock | useSyncStatus | 原地更新"我的钱包"卡片里的同步进度 |
tx | network, contract, txHash, from, to | TxList、首页的 waitIndexed、对方下拉框 | 与本列表地址有关才重新拉 /api/transactions |
swap | network, txHash, user, tokenIn, tokenOut | SwapPanel | 与当前账户有关才重新拉 /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 不够付金额 + Gas | INSUFFICIENT_FUNDS | ETH 余额不足以支付转账金额和 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 推送是怎样实现的。