系列导航
| 顺序 | 文章 | 定位 |
|---|---|---|
| 1 | 零代码全景:三条链、钱包、转账与兑换,以及智能合约在项目中的意义 | |
| 2 | 📍当前:智能合约详解 | MyToken 代币合约 + MiniSwap 兑换合约(AMM / 恒定乘积 / DeFi)+ 智能合约实际作用总结 |
| 3 | MetaMask / TronLink、ethers / tronWeb、转账与“授权并兑换”、报价与滑点 | |
| 4 | 事件监听(Transfer / Swap / 原生币扫块)、确认与重组、SSE 推送、数据库 |
本篇讲解项目的
contracts/目录里的两份合约:
- MyToken(MTK):一个 ERC20 代币——怎么写、怎么测、怎么部署,以及一笔转账在合约里是怎样一步步执行的;
- MiniSwap:一个迷你版 Uniswap V2 兑换合约——从“什么是 DeFi”讲到恒定乘积公式、价格影响、滑点保护,再逐段读源码。
文中代码均摘自本项目源码;命令输出均为本项目实测结果(2026-09,本地 anvil、Sepolia 测试网、TRON Nile 测试网)。
读完本篇你将学会
- 智能合约在一个 Web3 App 里负责什么、不负责什么。
- 看懂一个 40 多行的 ERC20 代币合约,以及它继承的 OpenZeppelin 核心代码。
- 理解 ABI:它是什么、从哪来、如何把“函数名 + 参数”编码成链能识别的字节。
- 用 Foundry 测试合约,并把合约部署到 EVM 链(anvil / Sepolia)和 TRON(Nile)。
- 跟着一笔真实交易,看清转账时 EVM 里每一步做了什么、花了多少 Gas。
- ★ 理解 DeFi 与 AMM:恒定乘积公式
x·y=k怎么推出兑换数量,为什么大单“越买越贵”。 - ★ 读懂 MiniSwap 源码里的安全设计:余额差值、防重入、检查-生效-交互、滑点与 deadline。
- ★ 总结智能合约在本项目里到底起了什么作用、有什么局限。
前置知识
建议先读第 1 篇《Web3 链上转账完整流程(小白版)》。本篇默认你已经知道:
- 钱包、地址、私钥:钱包保管私钥,用私钥“签字”发交易;EVM 地址是 0x 开头,TRON 地址是 T 开头(Base58 编码)。
- 区块链、智能合约:公共账本,以及部署在上面的一段程序。
- Gas:在链上执行操作要付的手续费(TRON 上叫能量 / 带宽,最终燃烧 TRX)。
- 原生币与代币:ETH、TRX 是链自带的原生币;MTK、USDT 是由合约记账的代币。
懂一点编程即可,不需要学过 Solidity,用到的语法会在讲到时解释。
目录
- 合约是什么:它在 App 里负责什么
- 开发工具:目录结构与 Foundry
- 源码逐段讲解:MyToken.sol
- ABI:合约的“接口说明书”
- 测试:不上链也能验证合约
- 部署:把合约放到三条链上
- 核心:转账时合约里发生了什么
- Gas:每一步花了多少钱
- 动手 Demo:只用命令行完成一次转账
- ★ MiniSwap:AMM、恒定乘积与 DeFi
- 常见问题
- ★ 总结:智能合约在本项目中的实际作用
- 本篇小结
- 下一篇预告
一、合约是什么:它在 App 里负责什么
这一章要解决的问题:在读任何代码之前,先弄清合约在整个系统里的位置和职责。
1.1 一句话定位:规则 + 账本
合约 = 规则 + 账本。 以 MTK 为例,MyToken 合约是整个 App 里唯一保存“谁有多少 MTK”的地方,也是唯一能修改 MTK 余额的地方。
打个比方:合约就像一家银行的“规章制度 + 总账本”,而且这本账本放在所有人都能看到的地方,谁都没法私下涂改。
本项目一共涉及三类合约,外加不需要合约的原生币:
| 资产 / 功能 | 由谁负责 | 本项目是否自己写 |
|---|---|---|
| ETH / TRX(原生币) | 链协议本身记账,不需要合约 | — |
| MTK(自发代币) | contracts/src/MyToken.sol | ✅ 自己写 |
| USDT(稳定币) | 链上已有的第三方合约 | ❌ 直接复用 |
| 兑换(Swap) | contracts/src/MiniSwap.sol | ✅ 自己写 |
下图是合约与前端、后端的关系:
┌──────────── 区块链(anvil / Sepolia / TRON Nile)────────────┐
│ │
前端 ──签名交易──▶ RPC 节点 ──▶ 虚拟机执行合约(MyToken / USDT / MiniSwap)
│ │ ──▶ 改账本 + 发事件(Transfer / Swap)
│ ▲ eth_call(只读,免费) │
前端 ──查余额 / 报价 ──────────────┘ │
│ │
后端 ◀──────────── 订阅 / 查询 Transfer、Swap 事件 ─────────────────────────┘
└─────────────────────────────────────────────────────────────┘
图中几个新词:
- EVM(Ethereum Virtual Machine,以太坊虚拟机):每个节点里运行合约代码的“虚拟电脑”。TRON 的 TVM 兼容绝大部分 EVM 指令,所以同一份 Solidity 源码可以部署到两边。
- RPC 节点:前端和后端访问区块链的入口,第 3 篇会详细讲。
- eth_call:只读调用,不上链、不花钱。
- 事件(event):合约执行时留下的一条“通知”,专门给链外程序看。
1.2 三条链与合约地址
| 链 | chainId | 原生币 | USDT(第三方) | MTK | MiniSwap |
|---|---|---|---|---|---|
| 本地链 anvil | 31337 | ETH | 无 | 0xdc64a140aa3e981100a9beca4e685f962f0cf6c9 | 未部署(本地只演示转账) |
| Sepolia 测试网 | 11155111 | ETH | 0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0(Aave 测试 USDT,6 位小数) | 0xcbee6a188946136b460c833b238eb5665e1f2447 | 0x65691a568b9ea2075325ffc4433c0f4f4c9eeafb |
| TRON Nile 测试网 | 3448148188 | TRX | 0xeca9bc828a3005b9a3b909f2cc5c2a54794de05f(TRC-20,6 位小数) | 0xd666326b58b01c0902e9b70360ca22dede5411b1 | 0xbbfaa366406e2fe989c46828175b92c2388416d3 |
TRON 地址在钱包和浏览器里显示为 T 开头的 Base58 格式,本质是
0x41前缀 + 20 字节,后 20 字节与上表的 0x 地址相同。例如 Nile USDT 就是TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf。所有地址都记录在frontend/src/abi/deployments.json。
1.3 与 Web2 后端的对比
| 对比 | Web2(传统后端) | Web3(智能合约) |
|---|---|---|
| 余额存在哪 | 公司自己的 MySQL | 合约的 storage,全网节点各存一份 |
| 谁能改余额 | 有数据库权限的人都能改 | 只能按合约代码的规则改,部署者也不能绕过 |
| 代码能否修改 | 随时发版 | 部署后不可修改(想改只能部署新合约) |
| 谁来运行 | 公司的服务器 | 全网所有节点,每个节点都执行一遍并得到相同结果 |
| 调用要钱吗 | 不要 | 读免费;写要付 Gas |
storage(存储):合约自己的永久数据区,保存在链上。可以理解成“合约专属的数据库”,只不过全网每个节点都存了一份。
1.4 合约负责什么、不负责什么
| 合约负责 | 合约不负责 |
|---|---|
| 保存每个地址的余额、池子的储备 | 用户昵称(后端数据库负责) |
| 校验操作是否合法(余额够不够、滑点超没超) | 交易 / 兑换历史列表(后端从事件中整理) |
| 执行转账、兑换,修改余额 | 页面展示、报价格式化(前端负责) |
| 发出 Transfer / Swap 事件,通知外界 | 管理私钥、签名(钱包负责) |
为什么昵称、历史列表不放在合约里?因为写 storage 是 EVM 里最贵的操作。所以链上只存“必须可信”的最小状态,其余都放到链下。
小结:合约只做一件事——按规则维护账本(余额、池子储备),并在每次变动时发出事件。展示、昵称、历史列表都交给前端和后端。
二、开发工具:目录结构与 Foundry
这一章要解决的问题:合约代码放在哪、用什么工具编译、测试和部署。
2.1 目录结构
contracts/
├── src/
│ ├── MyToken.sol # ★ 代币合约
│ └── MiniSwap.sol # ★ 兑换合约
├── test/
│ ├── MyToken.t.sol # 代币测试(6 个)
│ ├── MiniSwap.t.sol # 兑换测试(14 个)
│ └── mocks/FalseReturnToken.sol # 模拟“转账成功却返回 false”的 USDT
├── script/
│ ├── Deploy.s.sol # 部署 MyToken(anvil / Sepolia 共用)
│ └── DeploySwap.s.sol # 部署 MiniSwap 并注入三个池子的流动性(Sepolia)
├── export-abi.sh # 导出 MyToken 的 ABI 和部署信息给前端,打印后端配置
├── export-swap.sh # 导出 MiniSwap 的 ABI 和部署信息
├── foundry.toml # 编译器版本、优化器、RPC、tron 配置
├── lib/ # 依赖(git 子模块):forge-std、openzeppelin-contracts v5.7.0
├── out/ # EVM 编译产物(字节码 + ABI),不提交
├── out-tron/ # TRON 编译产物(evm_version = paris)
└── broadcast/ # forge script 的部署记录(按 chainId 分目录)
TRON 不走 forge script,而是由 Go 程序 backend/cmd/deploy-nile 通过 TRON HTTP API 部署(见第六章)。
2.2 Foundry 工具链
本项目使用 Foundry,一套用 Rust 写的合约开发工具:
| 工具 | 作用 | 本项目中的用法 |
|---|---|---|
forge | 编译、测试、部署 | forge build、forge test、forge script、forge lint |
cast | 在命令行里和链交互 | cast call 查余额、cast send 发交易、cast code 查字节码 |
anvil | 在本机启动一条测试链 | make anvil |
chisel | Solidity 交互式命令行 | 本项目未使用 |
可以把 forge 类比成前端的 npm run build + npm test,把 anvil 类比成本地启动的一个开发数据库。
2.3 编译配置 foundry.toml
solc_version = "0.8.37" # 固定编译器版本:保证每个人编译出的字节码一致
evm_version = "osaka" # 使用最新 EVM 指令(PUSH0、MCOPY 等更省 Gas)
optimizer = true
optimizer_runs = 10000 # 预计每个函数会被调用很多次 → 优化“调用成本”,代价是部署稍贵
[rpc_endpoints]
anvil = "http://127.0.0.1:8545"
sepolia = "${SEPOLIA_RPC_URL}" # 由 Makefile 从 backend/.env 读取后传入
# TRON(TVM)部署用:FOUNDRY_PROFILE=tron forge build
[profile.tron]
evm_version = "paris"
out = "out-tron"
- solc:Solidity 编译器,把
.sol源码编译成字节码(bytecode)。 - 优化器(optimizer):在编译时精简字节码,目的是省 Gas。
- 为什么 TRON 要用 paris? TVM 对新硬分叉引入的指令(Shanghai 的
PUSH0、Cancun 的MCOPY/TSTORE等)支持滞后,用 osaka 编译的字节码可能在 TVM 上执行失败,所以 TRON 版本退回到不含这些指令的 paris,并输出到单独的out-tron/,不覆盖 EVM 版本产物。这也是 MiniSwap 防重入只用普通 storage、不用 transient storage(TSTORE)的原因。
三、源码逐段讲解:MyToken.sol
这一章要解决的问题:读懂代币合约源码,知道每一行在做什么、为什么这样写。
合约源码只有 40 多行(含注释),核心逻辑都继承自 OpenZeppelin 的 ERC20。去掉注释后的主体:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
uint256 private constant INITIAL_SUPPLY = 1_000_000 * 1e18;
constructor() ERC20("", "") {
_mint(msg.sender, INITIAL_SUPPLY);
}
function name() public pure override returns (string memory) {
return "MyToken";
}
function symbol() public pure override returns (string memory) {
return "MTK";
}
}
SPDX-License-Identifier:声明开源协议。pragma solidity ^0.8.24:要求 0.8.24 及以上的 0.8.x 版本编译器。import {ERC20} ...:从 OpenZeppelin 库里引入 ERC20 的标准实现。
3.1 contract MyToken is ERC20:继承标准实现
ERC20 是以太坊上“同质化代币”的通用标准(接口规范)。“同质化”指每一枚代币都一样、可以互换,就像两张 100 元纸币没有区别。TRON 上的 TRC-20 与它接口完全一致。只要实现了下面这些函数和事件,任何钱包、交易所、区块浏览器都能识别它:
| 类型 | 名称 | 作用 | 读 / 写 |
|---|---|---|---|
| 函数 | name() / symbol() / decimals() | 代币名称、符号、小数位 | 读 |
| 函数 | totalSupply() | 总发行量 | 读 |
| 函数 | balanceOf(address) | 查某地址的余额 | 读 |
| 函数 | transfer(to, value) | 转账 | 写 |
| 函数 | approve / allowance / transferFrom | 授权别人代为转账(MiniSwap 收代币时用到) | 写 / 读 |
| 事件 | Transfer(from, to, value) | 每次余额变动都要发出 | — |
| 事件 | Approval(owner, spender, value) | 授权时发出 | — |
is ERC20 就是“继承”,和其他语言里的 extends 一样。OpenZeppelin 是业界最常用的合约库,代码经过大量审计。它内部维护着真正的账本:
// OpenZeppelin ERC20.sol(v5.7.0)内部状态
mapping(address account => uint256) private _balances; // 地址 → 余额
uint256 private _totalSupply; // 总量
mapping 就是链上的“数据库表”:key 是持币人地址,value 是余额,类比 JavaScript 的 Map<address, bigint>。
ERC20 里没有“图标”字段。 链上只有 name / symbol / decimals。钱包和浏览器里的代币图标都来自链下:钱包的
wallet_watchAsset(EIP-747,添加代币时传入图片 URL)、区块浏览器提交的代币信息、Token List(如 Uniswap token list)。本项目目前没有实现 watchAsset,MTK 在钱包里显示为默认图标;想要图标,可在前端调用wallet_watchAsset并传入image字段。
3.2 INITIAL_SUPPLY:为什么是 1_000_000 * 1e18
链上没有小数,所有金额都用整数表示。decimals = 18 的意思是:
1 MTK = 1 000000000000000000(1 后面 18 个 0)个“最小单位”
100 万 MTK = 1_000_000 × 10¹⁸ = 10²⁴ 个最小单位
类比:人民币最小单位是“分”,1 元 = 100 分。不同代币的小数位可以不同:MTK 18 位,USDT 6 位,TRX 也是 6 位(1 TRX = 10⁶ sun)。前后端显示金额时必须按各自的 decimals 换算,这在兑换时尤其要注意。
constant(常量)编译时直接写进字节码,不占 storage,读取时也不花 SLOAD 的 Gas。SLOAD 是“从 storage 读一个值”的指令;对应的写入指令 SSTORE 更贵。
3.3 constructor:部署时执行一次
- 构造函数只在部署时执行一次,之后永远不会再执行。
msg.sender是“当前这次调用是谁发起的”,部署时就是部署者(Sepolia 上是0x83Da…)。_mint(铸造) 把 100 万 MTK 记到部署者名下,并发出Transfer(0x000…000, 部署者, 100万)。from 是零地址,表示“凭空产生”,这就是数据库里第一条“铸造”记录的来源。
3.4 name() / symbol():一个 Gas 优化
OpenZeppelin 的默认做法是 ERC20("MyToken", "MTK"),把名称写进 storage。本项目改为给父构造函数传空字符串(部署省约 4 万 Gas),再用 pure 函数直接返回字符串常量。
想把代币改名叫 Dog?改这两个函数的返回值,然后重新部署。已部署的合约永远改不了,新部署会得到一个新地址。
小结:MyToken 自己只写了三件事——初始发行量、部署时铸币给部署者、返回名称和符号。账本和转账逻辑全部来自 OpenZeppelin。
四、ABI:合约的“接口说明书”
这一章要解决的问题:合约部署到链上之后,外界要怎么调用它?答案是 ABI。前端如何用 ABI 创建合约对象,见第 3 篇。
4.1 ABI 到底是什么
ABI(Application Binary Interface,应用二进制接口) 是一份 JSON “说明书”,描述合约有哪些函数、参数和返回值类型、会发出哪些事件、可能抛出哪些错误。
为什么需要它?因为部署到链上的只有字节码。下面是 Sepolia 上 MTK 合约的真实字节码开头(cast code 查询,全长 2154 字节):
0x608060405234801561000f575f5ffd5b506004361061009f575f3560e01c8063...
字节码里没有函数名、参数名、类型信息。EVM 只认“calldata 前 4 个字节是多少,就跳到哪段指令”。calldata(调用数据) 是交易附带的一串字节,写明“调用哪个函数、传什么参数”,类比 HTTP 请求的 body。
| Web2 | Web3 |
|---|---|
| 后端服务 | 部署在链上的合约(字节码) |
| OpenAPI / Swagger 文档 | ABI:函数、参数、返回值、事件、错误 |
TypeScript 的 .d.ts 类型声明 | ABI(配合 TypeChain 可生成 TS 类型) |
| axios 按接口文档拼 URL 和 body | ethers / tronWeb 按 ABI 编码 calldata |
4.2 ABI 从哪里来
ABI 由编译器根据源码自动生成,不需要手写:
contracts/src/MyToken.sol、MiniSwap.sol
│ forge build(solc 编译)
▼
contracts/out/<合约>.sol/<合约>.json ← 编译产物:字节码 + ABI + 元数据
│ export-abi.sh / export-swap.sh:jq '.abi' 提取
▼
frontend/src/abi/MyToken.abi.json、MiniSwap.abi.json ← 前端 import 的文件
ABI 与链无关:同一份源码用 osaka 或 paris 编译,字节码不同,但 ABI 相同,所以 EVM 和 TRON 共用一份 ABI 文件。两份合约的 ABI 条目:
| 合约 | function | event | error |
|---|---|---|---|
| MyToken | 9 个:allowance、approve、balanceOf、decimals、name、symbol、totalSupply、transfer、transferFrom | 2 个:Approval、Transfer | 6 个:ERC20InsufficientAllowance、ERC20InsufficientBalance 等 |
| MiniSwap | 10 个:swap、getAmountOut、getReserves、addLiquidity、removeLiquidity、NATIVE、FEE_BPS、owner、transferOwnership、renounceOwnership | 4 个:Swap、LiquidityAdded、LiquidityRemoved、OwnershipTransferred | 10 个:7 个自定义(Expired、InsufficientOutput 等)+ OwnableUnauthorizedAccount、OwnableInvalidOwner、ReentrancyGuardReentrantCall |
另外各有 1 个 constructor 条目。MiniSwap 里 owner 等 3 个函数和对应事件、错误来自继承的 OpenZeppelin Ownable、ReentrancyGuard。
4.3 一个真实的 ABI JSON 片段
下面摘自 frontend/src/abi/MyToken.abi.json:一个读函数、一个写函数、一个事件(错误条目格式与函数类似,只有 inputs):
[
{
"type": "function",
"name": "balanceOf",
"inputs": [
{ "name": "account", "type": "address", "internalType": "address" }
],
"outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "transfer",
"inputs": [
{ "name": "to", "type": "address", "internalType": "address" },
{ "name": "value", "type": "uint256", "internalType": "uint256" }
],
"outputs": [{ "name": "", "type": "bool", "internalType": "bool" }],
"stateMutability": "nonpayable"
},
{
"type": "event",
"name": "Transfer",
"inputs": [
{
"name": "from",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "to",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "value",
"type": "uint256",
"indexed": false,
"internalType": "uint256"
}
],
"anonymous": false
}
]
| 字段 | 含义 |
|---|---|
type | 条目类型:function / event / error / constructor / fallback / receive |
inputs / outputs | 参数、返回值列表,每项有名字和 ABI 类型 |
internalType | Solidity 源码里的原始类型,供工具参考,编码时不用 |
stateMutability | 函数会不会改链上状态,见下表 |
indexed | (仅事件)该参数是否放在 topics 里,可被节点高效过滤 |
stateMutability(状态可变性)决定了调用方式:
| 值 | 含义 | 本项目中的函数 | 调用方式 |
|---|---|---|---|
pure | 不读也不写链上状态 | MTK 的 name、symbol | eth_call,免费 |
view | 只读链上状态 | balanceOf、allowance、MiniSwap 的 getReserves、getAmountOut | eth_call,免费 |
nonpayable | 会修改状态,不能附带原生币 | transfer、approve、removeLiquidity | 发交易,要签名、付 Gas |
payable | 会修改状态,可以附带原生币 | MiniSwap 的 swap、addLiquidity | 发交易,可带 value |
一句话记忆:pure / view 是“查询”,免费;nonpayable / payable 是“办业务”,要签名付费。swap 是 payable,因为“卖 ETH / TRX”时原生币要随交易一起转进合约。
4.4 ABI 的两大作用:编码与解码
ABI 本身不能执行任何操作,它负责在“人能读的名字”和“链能读的字节”之间来回翻译。以下输出由前端安装的 ethers 6.17.0(Interface 类)实际运行得到。
① 选择器:链上认的是 4 个字节,而不是函数名
把签名字符串(名字 + 参数类型,无空格)做 keccak256 哈希(以太坊的哈希函数,任意输入得到固定 32 字节“指纹”),取前 4 字节就是函数选择器(function selector):
transfer(address,uint256) → 0xa9059cbb (函数:前 4 字节)
balanceOf(address) → 0x70a08231
ERC20InsufficientBalance(address,uint256,uint256) → 0xe450d38c (错误:同样前 4 字节)
Transfer(address,address,uint256) → 0xddf252ad…23b3ef (事件:完整 32 字节,即 topics[0])
$ cast sig "transfer(address,uint256)"
0xa9059cbb
$ cast keccak "Transfer(address,address,uint256)"
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
后端订阅时的过滤条件“合约地址 = MTK,topics[0] = 0xddf252ad…”,意思就是“只要 MTK 合约的 Transfer 事件”;监听兑换时换成 MiniSwap 地址和 Swap 事件的 topic 即可。
② 编码调用:函数名 + 参数 → calldata
iface.encodeFunctionData("transfer", ["0x455b…d6a0", 2500n * 10n ** 18n]);
0xa9059cbb ← 选择器
000000000000000000000000455bddc544682651f75af54a0c2124289993d6a0 ← to:address 左侧补零到 32 字节
0000000000000000000000000000000000000000000000878678326eac900000 ← value:2500 × 10¹⁸
规律:选择器 4 字节在前,后面每个参数各占 32 字节。(TRON 的 HTTP API 把“函数签名”和“参数”分开传,所以 deploy-nile 里的 packArgs 会去掉开头 4 字节,其余编码完全相同。)
③ 解码返回值
iface.decodeFunctionResult("balanceOf", "0x…014542ba12a337c00000");
// → 6000000000000000000000n → 6000.0 MTK
④ 解析事件日志
iface.parseLog({
topics: ["0xddf252ad…", "0x…f78c9972…", "0x…455bddc5…"],
data: "0x…878678326eac900000",
});
// → Transfer { from: '0xF78c…4Bf2', to: '0x455B…D6a0', value: 2500000000000000000000n }
indexed: true 告诉 ethers:from、to 在 topics[1]、topics[2],value 在 data 里。
⑤ 解析 revert 错误
revert(回滚) 是合约主动“拒绝执行”:整笔交易作废,并返回一段错误数据。余额不足时返回:
0xe450d38c ← ERC20InsufficientBalance 的选择器
000000000000000000000000f78c997295ae31e35a4e9debaa773d1df9f74bf2 ← sender
0000000000000000000000000000000000000000000000000000000000000000 ← balance = 0
0000000000000000000000000000000000000000000000000000000000000001 ← needed = 1
iface.parseError(errData); // → ERC20InsufficientBalance [ '0xF78c…4Bf2', 0n, 1n ]
这就是前端能按错误名给出友好提示的原因:ABI 里带着 error 条目。MiniSwap 的 InsufficientOutput、Expired 等自定义错误同理。
4.5 ABI 必须与合约一致
ABI 里声明了合约里不存在的函数,ethers 照样能编码,但链上找不到这个选择器,调用失败(真实运行结果):
const wrong = new Contract(
TOKEN,
["function mintForMe() view returns (uint256)"],
provider,
);
await wrong.mintForMe();
// CALL_EXCEPTION - execution reverted (no data present; likely require(false) occurred
所以 ABI 应该从编译产物中导出,不要手抄。本项目的 make deploy-* / make deploy-swap-sepolia 部署后会自动执行导出脚本。
4.6 合约、前端、后端如何使用 ABI
| 角色 | 如何使用 ABI | 位置 |
|---|---|---|
| 合约 | 编译时生成 ABI | contracts/out/…/*.json |
| 前端 | 导入完整 ABI JSON,交给 ethers / tronWeb 编码调用、解码结果和错误 | frontend/src/abi/*.abi.json |
| 后端 | 没有导入 ABI 文件,只手写用到的事件签名(Transfer、Swap),算出 topics[0],按 indexed 规则手动解析 | backend/listener/ |
| 区块浏览器 | 合约源码验证后拿到 ABI,才能显示方法名、解码日志 | Etherscan / Tronscan |
小结:ABI 是合约的接口说明书,由编译器自动生成。函数靠 4 字节选择器区分,参数按 32 字节一格排列,事件靠 topics 和 data 传递。
五、测试:不上链也能验证合约
这一章要解决的问题:合约部署后改不了,所以上链前必须先确认它没有 bug。
Foundry 的测试也用 Solidity 写,运行在本地 EVM 里,不需要任何链、不花任何钱。本章先看 MyToken 的测试,MiniSwap 的 14 个测试放在第十章讲。
5.1 测试源码(节选)
contract MyTokenTest is Test {
MyToken internal token;
address internal alice = makeAddr("alice"); // 部署者
address internal bob = makeAddr("bob"); // 收款方
function setUp() public {
vm.prank(alice); // 下一次调用的 msg.sender 伪装成 alice
token = new MyToken();
}
// 转账后双方余额正确
function test_Transfer() public {
vm.prank(alice);
bool ok = token.transfer(bob, 100e18);
assertTrue(ok);
assertEq(token.balanceOf(alice), 1_000_000e18 - 100e18);
assertEq(token.balanceOf(bob), 100e18);
}
// 余额不足必须失败,并带上 OpenZeppelin v5 的 custom error
function test_RevertWhen_InsufficientBalance() public {
vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, bob, 0, 1));
vm.prank(bob);
token.transfer(alice, 1);
}
// 模糊测试:随机生成 256 个金额,验证“转账前后总量守恒”
function testFuzz_TransferConservesBalance(uint256 amount) public {
amount = bound(amount, 0, token.balanceOf(alice));
vm.prank(alice);
token.transfer(bob, amount);
assertEq(token.balanceOf(alice) + token.balanceOf(bob), token.totalSupply());
}
}
setUp()在每个测试前运行一次,这里用来部署一个新合约。test_开头是普通测试;testFuzz_开头是模糊测试(fuzz testing):工具自动生成大量随机输入,检查规则是否始终成立。vm.prank、vm.expectEmit、vm.expectRevert是 Foundry 的“作弊码”(cheatcode),分别用来伪装调用者、断言会发出某个事件、断言会失败。
5.2 运行结果(真实输出)
$ cd contracts && forge test
Ran 6 tests for test/MyToken.t.sol:MyTokenTest
[PASS] testFuzz_TransferConservesBalance(uint256) (runs: 256, μ: 93628, ~: 93870)
[PASS] test_InitialSupplyGoesToDeployer() (gas: 26939)
[PASS] test_Metadata() (gas: 23543)
[PASS] test_RevertWhen_InsufficientBalance() (gas: 37464)
[PASS] test_Transfer() (gas: 76667)
[PASS] test_TransferEmitsEvent() (gas: 66338)
Suite result: ok. 6 passed; 0 failed; 0 skipped
Ran 14 tests for test/MiniSwap.t.sol:MiniSwapTest
...(见第十章)
Ran 2 test suites in 20.77ms (28.93ms CPU time): 20 tests passed, 0 failed, 0 skipped (20 total tests)
make test 等价于 cd contracts && forge test -vv。模糊测试的 μ(平均 Gas)每次随机输入不同,数值会略有浮动。
六、部署:把合约放到三条链上
这一章要解决的问题:测试通过后,怎样把合约发布到三条链上,以及前端、后端怎样拿到合约地址。
6.1 部署命令一览
| 链 | MyToken | MiniSwap | 签名方式 | 编译配置 |
|---|---|---|---|---|
| anvil | make deploy-local | 未部署 | --private-key anvil 公开的 0 号测试私钥 | default(osaka) |
| Sepolia | make estimate-sepolia 预估 → make deploy-sepolia | make estimate-swap-sepolia SENDER=… → make deploy-swap-sepolia | --account deployer:Foundry 加密保存的私钥,需输入密码 | default(osaka) |
| Nile | make deploy-nile | make deploy-swap-nile | 终端输入私钥(不回显、不落盘),经环境变量传给 Go 程序 | FOUNDRY_PROFILE=tron(paris) |
MyToken 在 Sepolia 上实测约 75.5 万 Gas,约 0.0014~0.0017 SepoliaETH。
6.2 EVM 链:forge script
// script/Deploy.s.sol
contract Deploy is Script {
function run() external returns (MyToken token) {
vm.startBroadcast(); // 之后的调用都会被签名,作为真实交易发到链上
token = new MyToken(); // ← 这一行就是“部署交易”
vm.stopBroadcast();
console.log("MyToken deployed at:", address(token));
}
}
部署本质上也是一笔交易,只是比较特殊:
- forge 把
new MyToken()变成一笔**to为空、data是合约字节码**的交易; - 用部署者私钥签名后通过 RPC 广播;
- 打包进区块后 EVM 执行:算出合约地址 → 执行 constructor(铸币、发 Transfer 事件)→ 把运行时字节码永久存到这个地址;
- 交易回执(receipt,交易执行完后链给出的“结果单”) 里带回
contractAddress,forge 保存到broadcast/Deploy.s.sol/<chainId>/run-latest.json。
6.3 合约地址是怎么算出来的
合约地址不是随机的,由“部署者地址 + 部署者当时的 nonce”决定。nonce 是账户已发出的交易数量,从 0 开始,类比支票簿的流水号。
合约地址 = keccak256(rlp([部署者地址, nonce])) 的后 20 字节
$ cast compute-address 0x83da6626ef0721da9d452a7eedc4f291a3f9d88c --nonce 0
0xCbeE6A188946136b460c833B238EB5665E1F2447 ← Sepolia 上的 MTK(部署者的第 1 笔交易)
$ cast compute-address 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 --nonce 4
0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 ← anvil 上的 MTK(Anvil-A 的第 5 笔交易)
因为地址可以提前算出,make estimate-sepolia 模拟部署时就能提前告诉你合约地址。
6.4 TRON Nile:为什么不能用 forge script
backend/cmd/deploy-nile/main.go 的注释说明了原因:Foundry 通过 eth_sendRawTransaction 广播以太坊格式的交易,而 TRON 的交易结构(protobuf)、签名对象、广播接口都不同,TronGrid 的 /jsonrpc 也不支持发交易。所以本项目:
- 用
FOUNDRY_PROFILE=tron forge build编译出 paris 版本字节码(out-tron/); - 由 Go 程序走 TRON 原生 HTTP API(
tron.go),流程和以太坊一一对应:
/wallet/deploycontract 或 /wallet/triggersmartcontract 节点构造“未签名”交易,返回 txID
▼
本地用私钥对 txID 签名(txID = sha256(raw_data),签名算法同样是 secp256k1)
▼
/wallet/broadcasttransaction 广播
▼
/wallet/gettransactioninfobyid 轮询到打包,拿到结果、合约地址、消耗的资源
go run ./cmd/deploy-nile(make deploy-nile):部署 MyToken;go run ./cmd/deploy-nile swap(make deploy-swap-nile):部署 MiniSwap 并注入流动性,可重复执行,见 10.11 节。
6.5 部署完成后:导出脚本把结果交给前后端
export-abi.sh <chainId>:导出MyToken.abi.json,把地址、部署区块、部署者写入deployments.json,并打印SEPOLIA_CONTRACT、SEPOLIA_START_BLOCK等后端配置;export-swap.sh <chainId>:导出MiniSwap.abi.json,把swap: {address, startBlock}合并写入同一条链的配置(不影响 MyToken 字段),并打印SEPOLIA_SWAP=…;- Nile 由
deploy-nile自己写deployments.json(格式相同),并打印NILE_CONTRACT、NILE_SWAP配置。
当前的 frontend/src/abi/deployments.json:
{
"11155111": {
"address": "0xcbee6a188946136b460c833b238eb5665e1f2447",
"owner": "0x83da6626ef0721da9d452a7eedc4f291a3f9d88c",
"startBlock": 11784949,
"swap": {
"address": "0x65691a568b9ea2075325ffc4433c0f4f4c9eeafb",
"startBlock": 11811830
}
},
"31337": {
"address": "0xdc64a140aa3e981100a9beca4e685f962f0cf6c9",
"owner": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
"startBlock": 517
},
"3448148188": {
"address": "0xd666326b58b01c0902e9b70360ca22dede5411b1",
"owner": "0xd36526f9d98f08283582691aebcafc2450d1f175",
"startBlock": 71377260,
"swap": {
"address": "0xbbfaa366406e2fe989c46828175b92c2388416d3",
"startBlock": 71403376
}
}
}
三方各取所需:前端读 address / swap.address,知道去哪个合约查余额、转账、兑换;后端用地址和 startBlock 知道监听哪个合约、从哪个区块开始同步;make seed-* 读 owner,把部署者以“合约持有人”的昵称写进 users 表。
小结:部署 = 发一笔
to为空、data为字节码的交易;EVM 链用 forge script,TRON 用 paris 编译 + HTTP API。部署后导出脚本把 ABI、地址和起始区块交给前后端。
七、核心:转账时合约里发生了什么
这一章要解决的问题:用户点下“转账”并在钱包里确认之后,交易进入合约,EVM 里到底一步步做了什么。
以 Sepolia 上的一笔真实交易为例:勇敢的鹦鹉(收款人1)转 2500 MTK 给迷糊的水獭(收款人2)。
交易哈希:0x80e152bb556f0d778c136a9461f75cbd080b6a7ebd077a02f2a309f97394f3a5
关于步骤编号:本系列把一次转账拆成连续编号的步骤。编码 calldata、钱包签名、广播、打包等发生在前端、钱包和节点上(见第 3 篇);本章从交易进入合约开始,编号为 ⑪~⑮。
第 ⑪ 步:EVM 根据函数选择器找到 transfer
EVM 读取 calldata 前 4 字节 0xa9059cbb,跳转到 transfer,把后面 64 字节解码成 to 和 value。此时 msg.sender = 签名者 = 收款人1 0xf78c…。合约只认 msg.sender:你只能转自己的钱,因为只有你的私钥能让 msg.sender 等于你的地址。
第 ⑫ 步:检查参数和余额
OpenZeppelin 源码(v5.7.0):
function transfer(address to, uint256 value) public virtual returns (bool) {
address owner = _msgSender(); // 就是 msg.sender
_transfer(owner, to, value);
return true;
}
function _transfer(address from, address to, uint256 value) internal {
if (from == address(0)) revert ERC20InvalidSender(address(0));
if (to == address(0)) revert ERC20InvalidReceiver(address(0)); // 不能转给零地址
_update(from, to, value);
}
调用链是 transfer → _transfer → _update(下划线开头是内部函数,外部不能直接调用)。进入 _update 后先检查余额:
uint256 fromBalance = _balances[from]; // SLOAD:读余额
if (fromBalance < value) {
revert ERC20InsufficientBalance(from, fromBalance, value); // 余额不足 → 整笔交易回滚
}
revert 撤销这笔交易的所有状态修改,就像银行柜员把整张单据作废。但已消耗的 Gas 照样扣,因为节点已经帮你算过了。
第 ⑬ 步:修改账本(真正的“转账”)
unchecked {
_balances[from] = fromBalance - value; // SSTORE:转出方减少
}
...
unchecked {
_balances[to] += value; // SSTORE:转入方增加
}
这两次 SSTORE 才是真正的“转账”。unchecked 表示跳过溢出检查:前面已确认 value <= fromBalance,省掉检查可以节约 Gas。
第 ⑭ 步:发出 Transfer 事件
emit Transfer(from, to, value);
事件不存在 storage 里,合约自己也读不到。它被写进交易回执的日志(log),专门给链外程序看,类比转账后银行发的短信通知。这笔交易的真实日志:
{
"address": "0xcbee6a188946136b460c833b238eb5665e1f2447",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x000000000000000000000000f78c997295ae31e35a4e9debaa773d1df9f74bf2",
"0x000000000000000000000000455bddc544682651f75af54a0c2124289993d6a0"
],
"data": "0x0000000000000000000000000000000000000000000000878678326eac900000",
"logIndex": "0x3f"
}
| 字段 | 值 | 含义 |
|---|---|---|
address | 0xcbee…2447 | 发出事件的合约:MTK |
topics[0] | 0xddf252ad… | 事件签名:Transfer(address,address,uint256) |
topics[1] | …f78c9972… | from(indexed 参数放在 topics,左侧补零到 32 字节):收款人1 |
topics[2] | …455bddc5… | to:收款人2 |
data | 0x…878678326eac900000 | value(非 indexed 参数放在 data)= 2500 × 10¹⁸ = 2500 MTK |
logIndex | 0x3f = 63 | 这条日志在整个区块里排第 63 个 |
为什么 from 和 to 要
indexed? topics 里的参数可以被节点高效过滤(如“只查转给某地址的记录”),类比数据库给某列建了索引。
第 ⑮ 步:交易执行完毕,生成回执
回执中 status = 1 表示成功,前端的 await tx.wait() 拿到的就是它。
一个有意思的细节:这笔交易回执里的
to是0xdb9b…7db3,而不是 MTK 合约。因为这个 MetaMask 账户开启了“智能账户”(EIP-7702 委托),转账先经过 MetaMask 的委托合约,再由它调用 MTK 的transfer。但 Transfer 事件依然由 MTK 合约发出,所以后端按“合约地址 + 事件签名”过滤照样能捕获到——这正是“监听事件”比“解析交易的 to 和 input”更可靠的原因。
7.1 转账全过程小结
calldata: 0xa9059cbb + to + value
│
▼
⑪ 函数选择器 → transfer(),msg.sender = 签名者
│
▼
⑫ 检查 to ≠ 0、余额 ≥ value ──── 不满足 ──▶ revert(状态回滚,Gas 照扣)
│ 满足
▼
⑬ SSTORE:_balances[from] -= value;_balances[to] += value
│
▼
⑭ emit Transfer(from, to, value) → 写入回执日志
│
▼
⑮ 回执 status = 1 → 前端 tx.wait() 返回;后端收到事件推送
一句话概括:找函数 → 查余额 → 改账本 → 发通知 → 出回执。
八、Gas:每一步花了多少钱
这一章要解决的问题:哪些操作贵、为什么贵。
make gas(forge test --gas-report)的真实输出(节选):
| 合约 | 函数 | 最小 | 平均 | 中位数 | 最大 | 说明 |
|---|---|---|---|---|---|---|
| MyToken | transfer | 24,364 | 50,232 | 51,483 | 51,519 | 两次 SSTORE |
| MyToken | balanceOf | 2,558 | 2,558 | 2,558 | 2,558 | 一次 SLOAD |
| MyToken | name | 370 | — | — | — | pure,不读 storage |
| MiniSwap | swap | 27,613 | 59,615 | 60,170 | 80,795 | 收款 + 算价 + 改储备 + 转出 + 事件 |
| MiniSwap | getAmountOut | 5,834 | 5,834 | 5,834 | 5,834 | 只读,前端报价用 |
| MiniSwap | addLiquidity | 24,597 | 112,304 | 115,233 | 118,649 | 仅 owner |
(MiniSwap 的最小值来自提前 revert 的失败用例;测试环境里不含 21,000 基础费。)
几个规律:
- 读函数通过
eth_call调用时完全免费,表中数字只是它们在交易内部被调用时的成本。 - 写 storage 最贵:把一个槽从 0 写成非 0(对方第一次收到 MTK)约 2 万 Gas;非 0 改非 0 约 3 千。所以“首次转给某人”比“再次转给他”贵。
swap比transfer贵,因为它内部还要调用代币合约的transferFrom/transfer(跨合约调用)、读写池子储备、发事件。- 加上每笔交易固定的 21,000 基础费,一次普通转账约 5 万 Gas。
为什么写 storage 这么贵?因为写进去的数据要被全网每个节点永久保存,链用高价来限制随意存数据。
九、动手 Demo:只用命令行完成一次转账
这一章要解决的问题:不用页面、不用钱包,亲手走一遍“查余额 → 转账 → 看事件”。
以下命令在 anvil 上执行(先 make anvil 启动本地链):
# 合约地址与两个 anvil 测试账户(公开的测试数据,只能在本地使用)
TOKEN=0xdc64a140aa3e981100a9beca4e685f962f0cf6c9
A=0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
B=0x70997970c51812dc3a010c7d01b50e0d17dc79c8
KEY_A=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# 1. 读:查询 B 的余额(eth_call,免费,不需要私钥)
cast call $TOKEN "balanceOf(address)(uint256)" $B --rpc-url anvil
# 2. 写:A 转 1 MTK 给 B(需要 A 的私钥签名,要付 Gas)
cast send $TOKEN "transfer(address,uint256)" $B 1000000000000000000 \
--private-key $KEY_A --rpc-url anvil
# 3. 再查 B 的余额:多了 1 MTK
cast call $TOKEN "balanceOf(address)(uint256)" $B --rpc-url anvil
# 4. 查询这个合约最近的 Transfer 事件(后端做的就是这件事)
cast logs --address $TOKEN "Transfer(address indexed,address indexed,uint256)" \
--from-block latest --rpc-url anvil
注意:上面的私钥是 anvil 公开的测试私钥,任何人都知道。绝对不要往这类地址转入真实资产,也不要把自己的真实私钥写进命令行。
如果后端正在同步本地链,执行第 2 步后它的日志会立刻出现这笔交易,页面列表里也会出现记录。这说明后端只关心链上的事件,不关心转账是从哪里发起的。
十、★ MiniSwap:AMM、恒定乘积与 DeFi
这一章要解决的问题:MTK 能转账了,但用户手里有 ETH、想要 MTK 怎么办?本章从“什么是 DeFi”讲起,一路推导兑换公式,用链上真实成交数据验算,最后逐段读懂 MiniSwap 源码、测试和部署。
10.1 什么是 DeFi?DEX 和中心化交易所有什么不同
DeFi(Decentralized Finance,去中心化金融):用智能合约实现的金融服务——兑换、借贷、理财等。规则写在合约里、公开透明、任何人都能用,不需要开户,也没有一家公司替你保管资产。
DEX(Decentralized Exchange,去中心化交易所) 就是 DeFi 里做“兑换”的那一类,比如 Uniswap。和币安这类中心化交易所(CEX) 对比:
| 对比 | 中心化交易所(CEX) | 去中心化交易所(DEX,如 Uniswap / 本项目 MiniSwap) |
|---|---|---|
| 资产在哪 | 充值到交易所的账户里,由交易所托管 | 一直在自己钱包里,兑换时才转给合约,一笔交易内换回来 |
| 怎么成交 | 订单簿撮合:买家挂买单、卖家挂卖单,价格对上才成交 | 和资金池成交:不需要对手方,池子随时接单 |
| 价格由谁决定 | 买卖双方的挂单 | 算法:池子里两种资产的数量比例 |
| 需要信任谁 | 交易所不跑路、不挪用、不被黑 | 合约代码本身(开源、可审计、不可篡改) |
| 门槛 | 注册、KYC | 有钱包就能用 |
订单簿(Order Book) 是什么?可以想象成一个菜市场的“求购 / 出售”公告板:
卖单(想卖 ETH) 买单(想买 ETH)
价格 1002 数量 0.5 价格 998 数量 1.2
价格 1001 数量 2.0 ← 差价 → 价格 997 数量 3.0
只有买价 ≥ 卖价时才撮合成交。它需要有足够多的人一直挂单(称为“做市商”),而且撮合要频繁读写订单,放在链上 Gas 太贵。于是 DEX 换了一个思路:不撮合,让一个“自动做市商”永远报价。
10.2 AMM 与流动性池:一个自动报价的“兑换柜台”
AMM(Automated Market Maker,自动做市商):用一个数学公式代替挂单的做市商,随时按公式给出价格并成交。
流动性池(Liquidity Pool):AMM 背后的“资金池”,里面同时放着两种资产,比如 0.5 ETH 和 5000 MTK。
直觉上可以把它想成一个自动兑换柜台:
- 柜台里有 ETH 和 MTK 两堆货;
- 你交进来 MTK,柜台按公式算出给你多少 ETH;
- 交易后 MTK 那堆变多、ETH 那堆变少,于是 ETH 变“稀缺”、价格自动上涨,下一个人再用 MTK 换 ETH 就更贵;
- 没有人手动改价格,价格完全由两堆货的比例决定。
当前价格 = 两种资产数量之比。例如池子里 0.5 ETH / 5000 MTK,则 1 ETH = 10000 MTK、1 MTK = 0.0001 ETH。
本项目部署时注入的初始流动性(第一次注入的比例就是初始价格,三个池子的价格必须互相一致,否则一上线就会被套利):
| 链 | 初始价格 | ETH/TRX-USDT 池 | ETH/TRX-MTK 池 | USDT-MTK 池 |
|---|---|---|---|---|
| Sepolia | 1 ETH = 1000 USDT = 10000 MTK | 0.5 ETH / 500 USDT | 0.5 ETH / 5000 MTK | 500 USDT / 5000 MTK |
| Nile | 1 TRX = 0.3 USDT = 3 MTK | 100 TRX / 30 USDT | 100 TRX / 300 MTK | 30 USDT / 300 MTK |
(数量来自 Makefile 的 SWAP_SEPOLIA_ENV 默认值和 backend/cmd/deploy-nile/swap.go 的 poolDefaults。)
10.3 恒定乘积公式 x·y=k:推导兑换数量
那“公式”到底是什么?Uniswap V2(以及本项目)用的是最经典的恒定乘积(Constant Product):
x · y = k
x:池子里资产 X 的数量(储备,reserve)y:池子里资产 Y 的数量k:两者乘积,每次兑换前后都不能变小
不考虑手续费时的推导:用户放进 Δx 个 X,想拿走 Δy 个 Y。兑换后要保持乘积不变:
(x + Δx) · (y − Δy) = x · y
展开:y − Δy = x·y / (x + Δx)
解得:Δy = y − x·y/(x + Δx) = y · Δx / (x + Δx)
加上 0.3% 手续费:Uniswap V2 的规则是先从输入里扣掉 0.3%,只拿剩下的 99.7% 参与公式:
Δx' = Δx × 0.997 (有效输入)
Δy = y · Δx' / (x + Δx')
链上没有小数,合约把 0.997 写成 9970 / 10000,分子分母同乘 10000,就得到 MiniSwap 里的 _amountOut:
uint256 public constant FEE_BPS = 30; // 0.3%,以万分比(bps,basis point)表示
uint256 private constant BPS = 10_000;
function _amountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) private pure returns (uint256) {
uint256 amountInWithFee = amountIn * (BPS - FEE_BPS); // Δx × 9970
return amountInWithFee * reserveOut / (reserveIn * BPS + amountInWithFee);
}
即:
amountOut = amountIn·9970·reserveOut / (reserveIn·10000 + amountIn·9970)
★ 手续费去哪了? 注意合约更新储备时,输入一侧加的是完整的 amountIn(包括那 0.3%),而输出是按扣费后的 Δx' 算的。所以兑换后 x·y 会略大于原来的 k——多出来的就是留在池子里的手续费,归流动性提供者。除法向下取整产生的零头也留在池子里。
用一张图看这条曲线(y = k/x 的双曲线):
y(MTK)
│
│● A:当前状态 (x, y)
│ ╲
│ ╲
│ ╲● B:卖入 Δx 后 (x+Δx, y−Δy)
│ ╲___
│ ╲______
│ ╲__________ ← 越往右越平:同样多的 Δx 换到的 Δy 越来越少
└──────────────────────────────── x(ETH)
交易就是在这条曲线上“滑动”:从 A 滑到 B。曲线永远不碰坐标轴,这是后面“池子永远买不空”的数学原因。
10.4 用真实成交逐步验算(例 1:Sepolia ETH-MTK 池)
初始:x = 0.5 ETH,y = 5000 MTK,k = 0.5 × 5000 = 2500,当前价格 1 MTK = 0.0001 ETH。
第 1 笔(11:27:48):卖 500 MTK 换 ETH
这里输入是 MTK,所以 reserveIn = 5000 MTK,reserveOut = 0.5 ETH:
有效输入 Δx' = 500 × 0.997 = 498.5 MTK
amountOut = 498.5 × 0.5 / (5000 + 498.5)
= 249.25 / 5498.5
≈ 0.045330 ETH ✓ 与链上记录 0.04533 ETH 一致
兑换后池子:5500 MTK / (0.5 − 0.045330) = 0.454670 ETH
第 2 笔(11:37:36):卖 0.01 ETH 换 MTK
现在输入是 ETH,reserveIn = 0.454670 ETH,reserveOut = 5500 MTK:
有效输入 Δx' = 0.01 × 0.997 = 0.00997 ETH
amountOut = 0.00997 × 5500 / (0.45467 + 0.00997)
= 54.835 / 0.46464
≈ 118.016 MTK ✓ 与链上记录 118.016236 MTK 一致
兑换后池子:0.464670 ETH / 5381.98 MTK
k 变大了:
兑换前:0.5 × 5000 = 2500
兑换后:0.46467 × 5381.98 ≈ 2500.8 > 2500
多出来的 0.8 就是两笔 0.3% 手续费留在池子里的效果。2026-09-30 实测 Sepolia 链上 ETH-MTK 池储备正是 0.46467 ETH / 5381.98 MTK——这个池子之后没有再成交过,与推算完全吻合。
同样可以验算其他记录:11:17:24 用 6 USDT 在 0.5 ETH / 500 USDT 池里换 ETH,
5.982 × 0.5 / (500 + 5.982) ≈ 0.005911 ETH✓;Nile 上 12:11:45 用 10 TRX 在 100 TRX / 30 USDT 池里换 USDT,9.97 × 30 / 109.97 ≈ 2.7198 USDT✓。
10.5 价格影响:为什么越买越贵
第 1 笔为什么只拿到 0.04533 ETH? 按兑换前的价格 1 MTK = 0.0001 ETH:
| 口径 | 500 MTK 能换 |
|---|---|
| 按当前价格、不扣手续费 | 0.05 ETH |
| 按当前价格、扣 0.3% 手续费 | 0.04985 ETH(“理想值”) |
| 实际成交(沿曲线滑动) | 0.04533 ETH |
价格影响(Price Impact) = 这笔交易让成交均价比当前价格差了多少:
价格影响 = (理想值 − 实际) / 理想值 = (0.04985 − 0.045330) / 0.04985 ≈ 9.07%
前端 utils/web3/swap.ts 的 priceImpactBps 用整数万分比计算并向下取整,得到 906 bps,页面显示 9.06%,与截图一致。
化简一下会发现一个好记的规律:价格影响 ≈ Δx' / (x + Δx'),也就是“你这笔单子占池子的比重”。498.5 / 5498.5 ≈ 9.07%——卖入的 MTK 相当于池中 MTK 的 10%,价格影响就接近 9%。所以:
- 池子越深(流动性越多)、单子越小 → 价格影响越小;
- 真实 Uniswap 的主流池子有上亿美元流动性,普通交易的价格影响可以忽略;本项目是测试池,只有几千 MTK,所以很容易出现几个百分点的价格影响。
合约测试里也有同样的例子:1 ETH 在 10 ETH / 10000 USDT 的池子里只换到约 906.6 USDT,而不是 1000(见 10.10 节 test_Swap_NativeToToken)。
10.6 极端例子(例 2:Nile TRX-MTK 浅池大单)
初始:100 TRX / 300 MTK,1 MTK ≈ 0.333 TRX。
12:10:09:卖 10 TRX 换 MTK
out = 9.97 × 300 / (100 + 9.97) ≈ 27.1983 MTK ✓ 链上记录 27.198326 MTK
池子变为 110 TRX / 272.80 MTK
12:19:27:卖 1000 MTK 换 TRX——这笔单子是池中 MTK 的 3.7 倍:
out = 997 × 110 / (272.80 + 997) ≈ 86.3678 TRX ✓ 链上记录 86.367818 TRX
池子变为 23.63 TRX / 1272.80 MTK
结果触目惊心:
| 指标 | 数值 |
|---|---|
| 按初始价格,1000 MTK 应值 | 约 333 TRX |
| 实际只换到 | 86.37 TRX |
| 价格影响(相对成交前价格) | ≈ 997 / (272.80 + 997) ≈ 78.5% |
| 成交后 MTK 价格 | 23.63 / 1272.80 ≈ 0.0186 TRX,比初始 0.333 跌了约 94% |
从这个例子能学到三件事:
- 浅池 + 大单 = 巨大的价格影响。真实世界里,把大单拆小、或去流动性更深的池子,才能拿到合理价格。
- 池子永远不会被买空。
amountOut = y·Δx'/(x+Δx')里分母永远比分子里的 Δx' 大,所以 amountOut 永远小于 y。就算卖 100 万 MTK,也只能无限接近、却拿不走全部 110 TRX。这是恒定乘积曲线“永不碰轴”的直接结果,也保证了池子永远能报价。 - 价格失衡与套利。这笔交易后,Nile 上 TRX-MTK 池里 MTK 极便宜(1 MTK ≈ 0.0186 TRX),而 USDT-MTK 等其他池子还保持着别的价格,三个池子之间出现了价差。在真实 DeFi 里,套利者会立即“低买高卖”:在便宜的池子买入 MTK、在贵的池子卖出,赚取差价,同时把各池价格拉回一致。DEX 的价格正是靠套利者维持与外部市场同步的,合约本身并不知道“外面的价格”。本项目是测试网,没有套利机器人,所以失衡会一直保留(2026-09-30 实测 Nile TRX-MTK 池仍是 23.63 TRX / 1272.80 MTK)。
10.7 价格影响 vs 滑点保护 vs deadline
这三个概念小白最容易混,放在一起对比:
| 概念 | 回答的问题 | 谁决定 | 在本项目里 |
|---|---|---|---|
| 价格影响(Price Impact) | “我这笔单子本身会把价格推动多少?” | 数学公式 + 池子深度,报价时就确定 | 前端 priceImpactBps 显示,只是提示,合约不检查 |
滑点保护(minAmountOut) | “从我报价到交易真正上链之间,别人的交易把价格推走了怎么办?” | 用户选择的滑点容忍度 | 前端提供 0.5% / 1% / 3%,minOut = 预计收到 × (1 − 滑点),合约不满足就整笔回滚 |
截止时间(deadline) | “交易在网络里卡了很久才被打包,还要按旧价格成交吗?” | 用户 / 前端设置的时间戳 | 前端设为当前时间 + 20 分钟,合约 block.timestamp > deadline 就回滚 |
用一个时间线理解:
t0 前端报价:预计收到 118.016 MTK,价格影响 X%(已知,确定)
用户选滑点 1% → minAmountOut = 116.84 MTK,deadline = t0 + 20 分钟
│
│ 交易在内存池等待打包……此期间别人的兑换可能先成交,池子储备变了
▼
t1 交易被执行:合约按“此刻”的储备重新计算 amountOut
├─ amountOut ≥ 116.84 且 t1 ≤ deadline → 成交
├─ amountOut < 116.84 → revert InsufficientOutput(滑点保护生效)
└─ t1 > deadline → revert Expired(交易过期)
为什么需要滑点保护? 除了正常的并发交易,还有一种攻击叫三明治攻击(Sandwich Attack):攻击者看到你在内存池里的大额买单,抢先买入推高价格,等你以高价成交后再卖出获利。minAmountOut 给了你一个“最差可接受价格”,攻击者最多只能把你挤压到这个底线。所以滑点容忍度不是越大越好:设得太大,等于允许别人从你身上赚更多。
10.8 流动性提供者、LP 份额与无常损失
池子里的钱从哪来?来自流动性提供者(LP,Liquidity Provider):他们把两种资产按当前比例存入池子,换取手续费收入。
- LP 份额(LP Token):在 Uniswap 里,存入资产会拿到一种“份额代币”,记录你占池子的百分之几;取回时按份额比例拿回两种资产(含累积的手续费)。
- 手续费收益:每笔兑换 0.3% 留在池子里(就是 10.4 节 k 变大的部分),按份额归所有 LP。
- 无常损失(Impermanent Loss):当两种资产的外部价格比例变化后,套利者会把池子调整到新价格,LP 取回的资产组合价值,往往低于“当初不存进池子、直接拿着两种币”的价值。之所以叫“无常”,是因为如果价格回到存入时的比例,这部分损失会消失;但如果此时取出,损失就成真了。手续费收入能否覆盖无常损失,是做 LP 的核心考量。
MiniSwap 为教学做的简化(与真实 Uniswap V2 的差异,见合约头部注释):
| 方面 | Uniswap V2 | MiniSwap |
|---|---|---|
| 谁能提供流动性 | 任何人 | 只有 owner(addLiquidity / removeLiquidity 带 onlyOwner) |
| LP 份额 | 发行 LP 代币 | 不发,owner 用 removeLiquidity(bps) 按万分比取回(10000 = 全部) |
| 池子组织方式 | 每个交易对一个独立 Pair 合约,由 Factory 创建 | 所有池子放在一个合约里,用两个地址区分 |
| 原生币 | 包装成 WETH 再交易 | 直接参与,用 address(0) 表示 |
| 价格预言机 | 有 TWAP(时间加权平均价格),供其他合约安全读取价格 | 无 |
| 闪电贷 | 有(Flash Swap:先借后还,同一笔交易内完成) | 无 |
| 多跳路由 | Router 合约支持 A→B→C | 无,只支持单池直接兑换 |
保留下来的是最核心的部分:恒定乘积定价、0.3% 手续费、滑点与 deadline 保护、防重入。
10.9 源码逐段讲解:MiniSwap.sol
① 状态:池子怎么存
contract MiniSwap is Ownable, ReentrancyGuard {
address public constant NATIVE = address(0); // 原生币(ETH / TRX)的表示
uint256 public constant FEE_BPS = 30;
uint256 private constant BPS = 10_000;
/// 两个资产按地址大小排序:token0 < token1,原生币 address(0) 总是 token0
struct Pool {
uint256 reserve0;
uint256 reserve1;
}
mapping(address token0 => mapping(address token1 => Pool)) private _pools;
- 继承 OpenZeppelin 的
Ownable(权限:谁是 owner)和ReentrancyGuard(防重入锁)。 - 嵌套 mapping 相当于一张以
(token0, token1)为联合主键的表。为什么要排序?让 “ETH-MTK” 和 “MTK-ETH” 指向同一个池子。_sort函数就做这件事:a < b ? (a, b) : (b, a)。 - 原生币用
address(0)。前端和后端里则用0xeeee…eeee作为“原生币”的占位地址(业界惯例),调用合约前再换成address(0)。 getReserves(tokenA, tokenB)按调用者传入的顺序返回储备,调用者不用关心内部排序(测试test_AddLiquidity_SetsReservesInCallerOrder专门验证了这一点)。
② 自定义 error 与事件
event Swap(address indexed user, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut);
event LiquidityAdded(address indexed token0, address indexed token1, uint256 amount0, uint256 amount1);
event LiquidityRemoved(address indexed token0, address indexed token1, uint256 amount0, uint256 amount1);
error IdenticalTokens();
error ZeroAmount();
error PoolNotFound();
error Expired();
error NativeValueMismatch(uint256 expected, uint256 actual);
error InsufficientOutput(uint256 amountOut, uint256 minAmountOut);
error TransferFailed();
- 自定义 error(custom error):代替
require(cond, "字符串")。错误信息只编码成 4 字节选择器 + 参数,比存一长串字符串更省 Gas;还能带参数,比如InsufficientOutput(实际, 最少),前端解码后能告诉用户“只能换到 X,低于你要求的 Y”。 Swap事件是后端记录兑换的唯一依据:user、tokenIn、tokenOut都是indexed,便于按用户过滤。合约注释特别说明:卖代币换原生币时,原生币是合约内部转账,不会产生任何代币 Transfer 事件,只能靠 Swap 事件知道“谁用多少什么换到了多少什么”。后端监听它入库,经/api/swaps提供给前端的“我的兑换记录”(详见第 4 篇)。
③ swap:核心函数
function swap(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, uint256 deadline)
external
payable
nonReentrant
returns (uint256 amountOut)
{
// forge-lint: disable-next-line(block-timestamp)
if (block.timestamp > deadline) revert Expired();
if (tokenIn == tokenOut) revert IdenticalTokens();
if (amountIn == 0) revert ZeroAmount();
(uint256 reserveIn, uint256 reserveOut) = getReserves(tokenIn, tokenOut);
if (reserveIn == 0 || reserveOut == 0) revert PoolNotFound();
// 1. 先收钱:以实际到账数量为准(兼容转账扣费的代币)
uint256 received = _pullIn(tokenIn, amountIn);
// 2. 按恒定乘积公式算出能换多少,并检查滑点
amountOut = _amountOut(received, reserveIn, reserveOut);
if (amountOut < minAmountOut) revert InsufficientOutput(amountOut, minAmountOut);
// 3. 先更新储备、再转出(“检查-生效-交互”顺序,配合 nonReentrant 防重入)
_setReserves(tokenIn, tokenOut, reserveIn + received, reserveOut - amountOut);
_pushOut(tokenOut, msg.sender, amountOut);
// forge-lint: disable-next-line(reentrancy-events)
emit Swap(msg.sender, tokenIn, tokenOut, received, amountOut);
}
用流程图看一次“卖 MTK 换 ETH”:
sequenceDiagram
participant U as 用户钱包
participant S as MiniSwap
participant T as MTK 合约
U->>T: ① approve(MiniSwap, 数量)(第一次确认)
U->>S: ② swap(MTK, 0x0, amountIn, minOut, deadline)(第二次确认)
S->>S: 检查 deadline / 参数 / 池子存在
S->>T: transferFrom(用户 → MiniSwap)
S->>T: balanceOf(MiniSwap) 前后对比 → received
S->>S: 公式算 amountOut,检查 ≥ minOut
S->>S: 更新储备(生效)
S->>U: call{value: amountOut}(交互:转出 ETH)
S-->>S: emit Swap(...)
- 卖代币要先 approve:合约要用
transferFrom从你账户里扣 MTK,必须先得到你的授权。前端把两步做成一个“授权并兑换”按钮,钱包会弹两次确认(已授权额度足够时只弹一次)。 - 卖原生币不用授权:ETH / TRX 随交易直接附带(EVM 的
value、TRON 的callValue),这就是swap必须是payable的原因。 - Swap 事件里记录的是
received(实际到账)而不是amountIn,保证记录与真实成交一致。
④ 检查-生效-交互(Checks-Effects-Interactions)
这是 Solidity 最重要的安全模式之一:
- 检查(Checks):deadline、参数、池子存在、滑点——任何一条不满足就 revert;
- 生效(Effects):先修改自己的状态(
_setReserves); - 交互(Interactions):最后才调用外部合约 / 给外部地址转钱(
_pushOut)。
为什么顺序重要?因为“给外部地址转 ETH”会执行对方的代码(如果对方是合约,会触发它的 receive 函数)。如果先转钱、后改储备,恶意合约可以在 receive 里再次调用 swap,此时储备还是旧的,就能按旧价格重复兑换、掏空池子——这就是著名的重入攻击(Reentrancy),2016 年 The DAO 事件就是这样被盗的。
细心的读者会发现:
_pullIn(收钱)其实在“生效”之前就调用了外部代币合约。这是因为必须先知道“实际到账多少”才能计算输出。这里的安全由第二道防线nonReentrant保证。
⑤ nonReentrant:第二道防线
nonReentrant 来自 OpenZeppelin 的 ReentrancyGuard:进入函数时把一个状态变量设为“已进入”,退出时恢复;如果执行期间有人再次进入任何带 nonReentrant 的函数,直接 revert ReentrancyGuardReentrantCall。相当于给整个合约上了一把锁,同一时刻只允许一个带锁的函数在执行。
MiniSwap 的 swap、addLiquidity、removeLiquidity 三个会转钱的函数都加了这把锁。合约注释提到:这里只用普通 storage、不用 transient storage(TSTORE),以便按 paris 编译部署到 TRON。
⑥ _pullIn:用余额差值确认到账
function _pullIn(address token, uint256 amount) private returns (uint256) {
if (token == NATIVE) {
if (msg.value != amount) revert NativeValueMismatch(amount, msg.value);
return amount;
}
// 用余额差值确认到账,不信任返回值
uint256 before = IERC20(token).balanceOf(address(this));
_call(token, abi.encodeCall(IERC20.transferFrom, (msg.sender, address(this), amount)));
uint256 received = IERC20(token).balanceOf(address(this)) - before;
// 有的代币转账失败时不 revert、只返回 false 且不转账:到账为 0 就当作失败
if (received == 0) revert TransferFailed();
return received;
}
- 原生币:钱已经随交易进来了,只需检查
msg.value和声明的amountIn一致。 - 代币:不看
transferFrom的返回值,而是比较转账前后合约自己的余额:- 兼容转账扣费的代币(转 100 只到账 98):按 98 计算,不会多给用户;
- 兼容返回值不可靠的代币:有的代币失败时不 revert 只返回 false,到账为 0 就判为失败,而不是让用户“用 0 兑换”。
_call用底层token.call(data)调用代币,只要求调用本身没 revert、且目标地址确实是合约(token.code.length > 0,防止对一个空地址调用“成功”却什么都没发生)。
⑦ _pushOut:转出,同样不信任返回值
function _pushOut(address token, address to, uint256 amount) private {
if (amount == 0) return;
if (token == NATIVE) {
// forge-lint: disable-next-line(arbitrary-send-eth)
(bool ok,) = to.call{value: amount}("");
if (!ok) revert TransferFailed();
return;
}
uint256 before = IERC20(token).balanceOf(address(this));
_call(token, abi.encodeCall(IERC20.transfer, (to, amount)));
// forge-lint: disable-next-line(incorrect-strict-equality)
if (before - IERC20(token).balanceOf(address(this)) != amount) revert TransferFailed();
}
- 原生币用
call{value: amount}发送,不限制接收方的 Gas(旧写法transfer()只给 2300 Gas,接收方是合约钱包时可能失败)。 - 代币用余额差值确认,且要求恰好转出
amount,多转少转都说明代币行为异常,整笔回滚。
★ 为什么不信任 transfer 的返回值?一个真实的坑。 ERC20 标准规定 transfer 返回 bool 表示成败,但TRON Nile 上的 USDT 转账执行成功了,返回值却是 false(实测 transfer 返回全 0,transferFrom / approve 返回 true)。如果合约写成常见的:
require(IERC20(token).transfer(to, amount), "transfer failed"); // ❌ 在 Nile USDT 上永远失败
那么“卖 TRX / MTK 换 USDT”永远会失败——钱明明转出去了,合约却以为失败并回滚。OpenZeppelin 的 SafeERC20 也会把 false 当失败。所以 MiniSwap 改为只看余额变化这个“事实”,不看返回值这个“说法”。
测试里用 test/mocks/FalseReturnToken.sol 复现了这种代币:
/// 模拟 TRON 上的 USDT(TetherToken):6 位精度,transfer 执行成功却返回 false
contract FalseReturnToken is ERC20 {
function transfer(address to, uint256 value) public override returns (bool) {
super.transfer(to, value);
return false; // 与 TRON USDT 一致:转账已完成,返回值却是 false
}
}
MiniSwapTest 里的 “USDT” 就是它,所以所有涉及 USDT 的测试都在这种“返回 false”的条件下通过。
⑧ addLiquidity / removeLiquidity:仅 owner
addLiquidity(tokenA, tokenB, amountA, amountB):payable+onlyOwner+nonReentrant。原生币只能出现在一边,msg.value必须恰好等于它的数量。第一次注入决定初始价格,之后的注入应按当前比例,否则会被套利。removeLiquidity(tokenA, tokenB, bps):按万分比取回,同样先改储备再转出。
10.10 测试:14 个 MiniSwap 测试
test/MiniSwap.t.sol 的 setUp 搭了一个和 Sepolia 同价格、但流动性大 20 倍的环境:1 ETH = 1000 USDT = 10000 MTK,三个池子分别为 10 ETH / 10000 USDT、10 ETH / 100000 MTK、10000 USDT / 100000 MTK;USDT 用 FalseReturnToken。测试里还有一个 expectedOut 函数,用同一个公式独立再算一遍,核对合约结果。
运行 cd contracts && forge test 的真实输出:
Ran 14 tests for test/MiniSwap.t.sol:MiniSwapTest
[PASS] testFuzz_Swap_ConstantProductNeverDecreases(uint256) (runs: 256, μ: 100392, ~: 100101)
[PASS] test_AddLiquidity_OnlyOwner() (gas: 44417)
[PASS] test_AddLiquidity_RevertsOnNativeValueMismatch() (gas: 49554)
[PASS] test_AddLiquidity_SetsReservesInCallerOrder() (gas: 25173)
[PASS] test_RemoveLiquidity_Half() (gas: 108776)
[PASS] test_Swap_BlocksReentrancy() (gas: 519969)
[PASS] test_Swap_NativeToToken() (gas: 112068)
[PASS] test_Swap_RevertsAfterDeadline() (gas: 47359)
[PASS] test_Swap_RevertsOnMissingPoolOrBadInput() (gas: 110230)
[PASS] test_Swap_RevertsOnNativeValueMismatch() (gas: 52269)
[PASS] test_Swap_RevertsWhenSlippageExceeded() (gas: 66151)
[PASS] test_Swap_RevertsWithoutApproval() (gas: 110359)
[PASS] test_Swap_TokenToNative() (gas: 86031)
[PASS] test_Swap_TokenToToken_WithFalseReturnToken() (gas: 175231)
Suite result: ok. 14 passed; 0 failed; 0 skipped
Ran 2 test suites: 20 tests passed, 0 failed, 0 skipped (20 total tests)
按验证的内容分组:
| 分组 | 测试 | 验证什么 |
|---|---|---|
| 流动性 | test_AddLiquidity_SetsReservesInCallerOrder | 储备按调用者传参顺序返回 |
test_AddLiquidity_OnlyOwner | 非 owner 调用 revert OwnableUnauthorizedAccount | |
test_AddLiquidity_RevertsOnNativeValueMismatch | 附带的 ETH 与声明数量不符就 revert | |
test_RemoveLiquidity_Half | 取回 5000 bps(一半),储备和 owner 余额都正确 | |
| 正常兑换 | test_Swap_NativeToToken | 1 ETH → 约 906.6 USDT(报价 = 实际成交 = 独立计算;Swap 事件正确;返回 false 的 USDT 照样到账) |
test_Swap_TokenToNative | 1000 MTK → ETH | |
test_Swap_TokenToToken_WithFalseReturnToken | USDT ⇄ MTK 两个方向(收 USDT 用 transferFrom、付 USDT 用返回 false 的 transfer)都能成功 | |
| 保护机制 | test_Swap_RevertsWhenSlippageExceeded | minAmountOut 比报价多 1 → revert InsufficientOutput(want, want+1) |
test_Swap_RevertsAfterDeadline | vm.warp 把时间拨到 1000,deadline=999 → revert Expired | |
test_Swap_RevertsOnNativeValueMismatch | 声明 1 ETH 只附带 0.9 ETH → revert | |
test_Swap_RevertsOnMissingPoolOrBadInput | 池子不存在 / 两边同币 / 数量为 0,各自对应的 error | |
test_Swap_RevertsWithoutApproval | 有余额但没 approve → transferFrom 失败 → TransferFailed | |
| 安全 | test_Swap_BlocksReentrancy | 恶意合约 Reenterer 在收到 ETH 的 receive 里再次调用 swap,被 nonReentrant 拦下,整笔回滚 |
| 不变量 | testFuzz_Swap_ConstantProductNeverDecreases | 随机 256 个金额:兑换后 x·y 不变小、用户拿到的 < 池子储备、合约 ETH 余额与账面一致 |
两个值得细看的:
- 重入测试:攻击合约卖 MTK 换 ETH,MiniSwap 用
call给它转 ETH 时触发它的receive,它在里面再次调用swap。第二次进入被ReentrancyGuardReentrantCall拒绝 → 攻击者的receiverevert → MiniSwap 的call返回ok = false→ 抛TransferFailed→ 整笔回滚,攻击者一无所获。 - 模糊测试把 10.3 节“k 只增不减”和 10.6 节“池子永远买不空”变成了自动化检查:
assertGe(x1 * y1, x0 * y0)、assertLt(out, y0)。
10.11 部署 MiniSwap
Sepolia:forge script + --slow
script/DeploySwap.s.sol 在一次广播里做 6 笔交易:
vm.startBroadcast();
swap = new MiniSwap(); // 1. 部署
IERC20(mtk).approve(address(swap), type(uint256).max); // 2. 授权 MTK
IERC20(usdt).approve(address(swap), type(uint256).max); // 3. 授权 USDT
swap.addLiquidity{value: nu}(NATIVE, usdt, nu, …); // 4. ETH-USDT 池
swap.addLiquidity{value: nm}(NATIVE, mtk, nm, …); // 5. ETH-MTK 池
swap.addLiquidity(usdt, mtk, …); // 6. USDT-MTK 池
vm.stopBroadcast();
代币地址和各池数量由 Makefile 的 SWAP_SEPOLIA_ENV 通过环境变量传入(MTK 地址从 deployments.json 读取,数量可覆盖)。
make estimate-swap-sepolia SENDER=0x部署者地址 # 只模拟,不上链、不花钱
make deploy-swap-sepolia # 真正部署,结束后自动执行 export-swap.sh 11155111
为什么要加 --slow? 默认情况下 forge 会把这 6 笔交易一口气发给节点。但部署账户开启了 EIP-7702 委托(例如 MetaMask 智能账户)时,节点只允许它有 1 笔待打包交易,批量发送会报 in-flight transaction limit reached。--slow 让 forge 每笔上链后再发下一笔。中途失败可以用 FORGE_ARGS=--resume 从未发送的交易续跑。
Nile:deploy-nile swap,幂等部署
make deploy-swap-nile 先 FOUNDRY_PROFILE=tron forge build,再运行 go run ./cmd/deploy-nile swap。swap.go 的 deploySwap 每一步都先检查是否已完成:
1/3 部署 MiniSwap deployments.json 里已有 swap 地址 → 跳过
检查余额 算出还需注入的池子与数量;TRX 额外预留 150 TRX 手续费;不够就在花钱之前退出
2/3 授权代币 allowance 已足够 → 跳过
3/3 注入流动性 getReserves 显示池子已有流动性 → 跳过;原生币一侧通过 call_value 附带
幂等(Idempotent) 的意思是“执行一次和执行多次效果相同”。为什么要这样设计?TRON 上每一步都要燃烧 TRX,中途失败(比如 TRX 不够)时,补足后重新运行即可,已完成的步骤不会重复花钱,也不会重复部署出第二个合约。
10.12 lint 警告:为什么用注释抑制而不改代码
forge lint 会对 MiniSwap 报出若干“可能有风险”的写法,项目的处理方式是在对应行上方加 // forge-lint: disable-next-line(规则名) 并写明理由,而不是改代码:
| lint 规则 | 位置 | 为什么这里是安全的(摘自源码注释) |
|---|---|---|
block-timestamp | swap 的 deadline 判断 | deadline 以分钟计,出块者只能把时间戳微调几秒,不影响判断 |
reentrancy-events | swap 等函数末尾的 emit | 事件要记录实际成交值,只能放在转账之后;整个函数有 nonReentrant,无法重入伪造事件 |
non-reentrant-not-first | addLiquidity / removeLiquidity | onlyOwner 只读存储、不发起外部调用,放在前面不留重入窗口,还能让非 owner 更早失败 |
arbitrary-send-eth | _pushOut 发送原生币 | to 只可能是 msg.sender(兑换者本人或 owner),不是任意地址 |
incorrect-strict-equality | _pushOut 余额差值 != | 故意严格相等:多转少转都说明代币异常,应整笔回滚 |
加注释后 forge lint src 没有任何输出。
为什么不改代码? 因为合约已经部署到 Sepolia 和 Nile,而这些警告经分析都不是真问题。改逻辑就意味着链上合约和源码不再对应,要么重新部署(新地址、重新注入流动性、前后端改配置),要么留下“源码 ≠ 链上”的困惑。注释不影响编译出的指令:实测把本地编译的 MiniSwap 运行时字节码与 Sepolia 链上 cast code 的结果对比,两者长度相同(5400 字节),除末尾 53 字节的元数据(metadata)外完全一致。
末尾那段元数据是编译器附加的 CBOR 编码信息,含源码文件的哈希,所以只要源码(哪怕只是注释)变了它就会变,它不参与执行。这意味着:执行逻辑完全一致;但如果要在 Etherscan 上做“完全匹配”的源码验证,需要用部署时的源码版本,或者选择“部分匹配”(需要确认:本项目目前是否在 Etherscan 做了源码验证)。
本章小结:DEX 用 AMM 代替订单簿,价格由池子比例决定;恒定乘积
x·y=k推出amountOut = y·Δx'/(x+Δx'),0.3% 手续费让 k 略增;价格影响取决于单子占池子的比例,浅池大单代价巨大;minAmountOut防的是“别人”推动价格,deadline防的是交易卡太久;MiniSwap 用余额差值、检查-生效-交互、nonReentrant三重设计保证安全,并兼容 Nile USDT 返回 false 的怪癖。
十一、常见问题
Q:合约部署后发现 bug 怎么办? 改不了。只能修复后重新部署新合约(新地址),并更新前后端配置。生产环境会用“可升级代理合约”模式,本项目为了简单没有采用。
Q:我的 MTK 存在钱包里吗?
不在。存在 MTK 合约的 _balances 里。钱包只保管私钥,“导入代币”只是让钱包去合约查余额并显示出来。
Q:转账失败了为什么还扣手续费? 节点已经执行了你的交易,直到 revert 那一刻消耗的计算都要付费。所以前端会先做校验(余额够不够、报价是否满足滑点),尽量不发注定失败的交易。
Q:为什么 transfer 返回 bool,却几乎总是 true?
ERC20 的历史设计。OpenZeppelin v5 失败时直接 revert,所以执行成功就一定是 true。但别的代币不一定遵守:Nile USDT 成功也返回 false,这就是 MiniSwap 用余额差值判断的原因。
Q:兑换时为什么钱包要弹两次?
卖代币时,第一次是 approve(授权 MiniSwap 从你账户扣代币),第二次才是 swap。卖 ETH / TRX 不需要授权,只弹一次。授权额度足够时也只弹一次。
Q:兑换页面显示的“当前价格”和实际成交价为什么不一样? 当前价格是池子此刻的比例(相当于“买第一个单位”的价格),成交是沿曲线滑动的平均价,差距就是价格影响。单子越大、池子越浅,差得越多。
Q:本地 anvil 为什么没有 MiniSwap? 本地链上没有 USDT,且本项目在本地只演示转账;兑换功能部署在 Sepolia 和 Nile 两条测试网上。
Q:anvil 上为什么部署过两次(两个 MTK 地址)?
每次 make deploy-local 都会部署一个全新的合约,各有独立的 100 万 MTK。前后端只使用配置中的那一个(0xdc64…)。
十二、★ 总结:智能合约在本项目中的实际作用
这一章要解决的问题:读完两个合约之后,回到最初的问题——本项目为什么需要智能合约?它们各自解决了什么?又有哪些做不到的事?
12.1 按合约逐一看
| 对象 | 是否需要合约 | 在本项目中的作用 | 前后端需要什么 |
|---|---|---|---|
| MyToken(MTK) | ✅ 自己写 | 发行一种资产:合约 = 账本(_balances)+ 规则(只能转自己的钱、不能超额、总量 100 万)。部署者也不能随意改余额 | ABI + 三条链上各自的地址;后端监听 Transfer 事件 |
| USDT(第三方) | ✅ 但复用 | 链上已有的稳定币合约(Sepolia 是 Aave 测试 USDT,Nile 是官方测试 USDT)。同样是 ERC20 / TRC-20 接口,不需要自己部署,只需要 ABI + 地址就能查余额、转账、授权 | 通用 ERC20 ABI + 地址;注意 Nile USDT 返回值的怪癖 |
| MiniSwap | ✅ 自己写 | 无需信任的兑换与做市:用户直接和合约成交,不需要把资产交给任何人托管;价格由算法(恒定乘积)决定,没有人能单独改价;成交记录通过 Swap 事件公开 | MiniSwap ABI + 地址;卖代币前先 approve;后端监听 Swap |
| ETH / TRX(原生币)转账 | ❌ 不需要 | 原生币余额由链协议本身记录在账户状态里,转账就是一笔普通交易(to = 收款人、value = 金额),不执行任何合约代码 | 无 ABI;因为没有合约就没有事件,后端只能逐块扫描交易(native.go) |
从这张表能看出一条清晰的分界线:链协议只内置了“原生币记账”这一种规则;任何其他规则——发一种新币、按公式兑换——都需要用智能合约写出来。
12.2 智能合约的通用价值
| 价值 | 含义 | 本项目中的体现 |
|---|---|---|
| 可编程的规则 | 把业务规则写成代码,由全网节点强制执行 | MTK 的“余额不足就 revert”;MiniSwap 的“按公式定价、滑点不满足就回滚” |
| 不可篡改 | 部署后代码无法修改,任何人(包括部署者)都不能绕过 | owner 也无法改用户余额、无法改兑换公式,只能管理自己注入的流动性 |
| 无需许可 | 不用注册、不用审批,有钱包就能调用 | 任何地址都能直接调用 swap,不需要项目方开通账号 |
| 可组合性 | 合约可以调用其他合约,像乐高一样拼装 | MiniSwap 直接调用 MTK 和第三方 USDT 的 transferFrom / transfer,无需它们配合改动 |
| 事件供链下索引 | 合约用事件广播“发生了什么”,链下程序据此构建查询服务 | 后端监听 Transfer / Swap 入库,前端展示交易和兑换记录 |
| 透明可验证 | 代码和状态公开,任何人都能验证 | 本文能用 cast code 对比链上字节码,也能用公开的储备数据验算每一笔兑换 |
12.3 智能合约的局限
| 局限 | 说明 | 常见解决方案 / 本项目情况 |
|---|---|---|
| 部署后不可修改 | 发现 bug 不能热修复 | 可升级代理合约(Proxy)模式;本项目未采用,改动需重新部署(所以 lint 警告选择注释抑制) |
| Gas 成本 | 每次写操作都要付费,存储尤其贵 | 链上只存必要状态(余额、储备),昵称、历史列表放链下;MyToken 的 name/symbol 用 pure 函数 |
| 安全风险 | 代码公开 + 管理真金白银 = 攻击者的靶子;一旦出错资金无法追回 | 重入锁、检查-生效-交互、余额差值、充分测试(含模糊测试)、专业审计 |
| 不能主动访问链外数据 | 合约只能读链上状态,不能自己发 HTTP 请求去查“ETH 现在多少美元” | 预言机(Oracle),如 Chainlink,由链下节点把数据写上链;MiniSwap 没有预言机,价格只来自池子比例,靠套利者与外部市场对齐 |
| 不能主动执行 | 合约只能被交易触发,不能定时自己运行 | 链下程序(如 Keeper / 机器人)定时发交易 |
| 兼容性差异 | 不同代币、不同链的行为不完全一致 | Nile USDT 返回 false → 余额差值;TVM 对新指令支持滞后 → paris 编译 |
一句话总结:智能合约把“规则”从某家公司的服务器搬到了全网共同执行的公共账本上——MyToken 让资产可以被发行和转移,MiniSwap 让资产可以在无人托管的情况下按公开算法兑换;代价是代码难以修改、每步都要付费、安全要求极高,且看不见链外的世界。
十三、本篇小结
- 合约 = 规则 + 账本:余额、储备存在合约的 storage 里,只能按代码规则修改,部署后不可更改。
- MyToken 很短:核心逻辑继承自 OpenZeppelin 的 ERC20,自己只定义了发行量、铸币和名称。
- ABI 是接口说明书:函数靠 4 字节选择器识别,参数按 32 字节编码,事件分成 topics 和 data;EVM 与 TRON 共用 ABI。
- 先测试、再部署:20 个 Foundry 测试不花钱;EVM 链用 forge script(委托账户加
--slow),TRON 用 paris 编译 + HTTP API 幂等部署。 - 转账的本质:
msg.sender的余额减少、to的余额增加(两次 SSTORE),再发出一个 Transfer 事件。 - AMM 的本质:
x·y=k,amountOut = y·Δx'/(x+Δx'),价格由池子比例决定,0.3% 手续费让 k 略增;价格影响 ≈ 单子占池子的比例。 - MiniSwap 的安全设计:滑点保护 + deadline、检查-生效-交互 +
nonReentrant、余额差值不信任返回值。
下一篇预告
本篇我们站在合约内部,看清了转账和兑换在链上怎样执行。但用户不会用命令行操作——他们在网页上点按钮,在 MetaMask 或 TronLink 里确认。
下一篇《前端详解》会回答:
- 前端怎样通过 EIP-6963 发现 MetaMask、通过
window.tron连接 TronLink? - ethers 和 tronWeb 如何拿着本篇导出的 ABI 和地址查余额、发起转账?
- 兑换面板如何调用
getAmountOut报价、计算价格影响和minAmountOut、完成“授权并兑换”? - 交易发出后,页面如何通过后端推送实时更新记录?