系列导航

顺序文章定位
1Web3 链上转账完整流程(小白版)零代码全景:三条链、钱包、转账与兑换,以及智能合约在项目中的意义
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 测试网)。

读完本篇你将学会

  1. 智能合约在一个 Web3 App 里负责什么、不负责什么。
  2. 看懂一个 40 多行的 ERC20 代币合约,以及它继承的 OpenZeppelin 核心代码。
  3. 理解 ABI:它是什么、从哪来、如何把“函数名 + 参数”编码成链能识别的字节。
  4. 用 Foundry 测试合约,并把合约部署到 EVM 链(anvil / Sepolia)和 TRON(Nile)。
  5. 跟着一笔真实交易,看清转账时 EVM 里每一步做了什么、花了多少 Gas。
  6. ★ 理解 DeFi 与 AMM:恒定乘积公式 x·y=k 怎么推出兑换数量,为什么大单“越买越贵”。
  7. ★ 读懂 MiniSwap 源码里的安全设计:余额差值、防重入、检查-生效-交互、滑点与 deadline。
  8. ★ 总结智能合约在本项目里到底起了什么作用、有什么局限。

前置知识

建议先读第 1 篇《Web3 链上转账完整流程(小白版)》。本篇默认你已经知道:

  • 钱包、地址、私钥:钱包保管私钥,用私钥“签字”发交易;EVM 地址是 0x 开头,TRON 地址是 T 开头(Base58 编码)。
  • 区块链、智能合约:公共账本,以及部署在上面的一段程序。
  • Gas:在链上执行操作要付的手续费(TRON 上叫能量 / 带宽,最终燃烧 TRX)。
  • 原生币与代币:ETH、TRX 是链自带的原生币;MTK、USDT 是由合约记账的代币。

懂一点编程即可,不需要学过 Solidity,用到的语法会在讲到时解释。


目录

  1. 合约是什么:它在 App 里负责什么
  2. 开发工具:目录结构与 Foundry
  3. 源码逐段讲解:MyToken.sol
  4. ABI:合约的“接口说明书”
  5. 测试:不上链也能验证合约
  6. 部署:把合约放到三条链上
  7. 核心:转账时合约里发生了什么
  8. Gas:每一步花了多少钱
  9. 动手 Demo:只用命令行完成一次转账
  10. ★ MiniSwap:AMM、恒定乘积与 DeFi
  11. 常见问题
  12. ★ 总结:智能合约在本项目中的实际作用
  13. 本篇小结
  14. 下一篇预告

一、合约是什么:它在 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(第三方)MTKMiniSwap
本地链 anvil31337ETH无0xdc64a140aa3e981100a9beca4e685f962f0cf6c9未部署(本地只演示转账)
Sepolia 测试网11155111ETH0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0(Aave 测试 USDT,6 位小数)0xcbee6a188946136b460c833b238eb5665e1f24470x65691a568b9ea2075325ffc4433c0f4f4c9eeafb
TRON Nile 测试网3448148188TRX0xeca9bc828a3005b9a3b909f2cc5c2a54794de05f(TRC-20,6 位小数)0xd666326b58b01c0902e9b70360ca22dede5411b10xbbfaa366406e2fe989c46828175b92c2388416d3

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
chiselSolidity 交互式命令行本项目未使用

可以把 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。

Web2Web3
后端服务部署在链上的合约(字节码)
OpenAPI / Swagger 文档ABI:函数、参数、返回值、事件、错误
TypeScript 的 .d.ts 类型声明ABI(配合 TypeChain 可生成 TS 类型)
axios 按接口文档拼 URL 和 bodyethers / 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 条目:

合约functioneventerror
MyToken9 个:allowance、approve、balanceOf、decimals、name、symbol、totalSupply、transfer、transferFrom2 个:Approval、Transfer6 个:ERC20InsufficientAllowance、ERC20InsufficientBalance 等
MiniSwap10 个:swap、getAmountOut、getReserves、addLiquidity、removeLiquidity、NATIVE、FEE_BPS、owner、transferOwnership、renounceOwnership4 个:Swap、LiquidityAdded、LiquidityRemoved、OwnershipTransferred10 个: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 类型
internalTypeSolidity 源码里的原始类型,供工具参考,编码时不用
stateMutability函数会不会改链上状态,见下表
indexed(仅事件)该参数是否放在 topics 里,可被节点高效过滤

stateMutability(状态可变性)决定了调用方式:

值含义本项目中的函数调用方式
pure不读也不写链上状态MTK 的 name、symboleth_call,免费
view只读链上状态balanceOf、allowance、MiniSwap 的 getReserves、getAmountOuteth_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位置
合约编译时生成 ABIcontracts/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 部署命令一览

链MyTokenMiniSwap签名方式编译配置
anvilmake deploy-local未部署--private-key anvil 公开的 0 号测试私钥default(osaka)
Sepoliamake estimate-sepolia 预估 → make deploy-sepoliamake estimate-swap-sepolia SENDER=… → make deploy-swap-sepolia--account deployer:Foundry 加密保存的私钥,需输入密码default(osaka)
Nilemake deploy-nilemake 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));
    }
}

部署本质上也是一笔交易,只是比较特殊:

  1. forge 把 new MyToken() 变成一笔**to 为空、data 是合约字节码**的交易;
  2. 用部署者私钥签名后通过 RPC 广播;
  3. 打包进区块后 EVM 执行:算出合约地址 → 执行 constructor(铸币、发 Transfer 事件)→ 把运行时字节码永久存到这个地址;
  4. 交易回执(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 也不支持发交易。所以本项目:

  1. 用 FOUNDRY_PROFILE=tron forge build 编译出 paris 版本字节码(out-tron/);
  2. 由 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"
}
字段值含义
address0xcbee…2447发出事件的合约:MTK
topics[0]0xddf252ad…事件签名:Transfer(address,address,uint256)
topics[1]…f78c9972…from(indexed 参数放在 topics,左侧补零到 32 字节):收款人1
topics[2]…455bddc5…to:收款人2
data0x…878678326eac900000value(非 indexed 参数放在 data)= 2500 × 10¹⁸ = 2500 MTK
logIndex0x3f = 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)的真实输出(节选):

合约函数最小平均中位数最大说明
MyTokentransfer24,36450,23251,48351,519两次 SSTORE
MyTokenbalanceOf2,5582,5582,5582,558一次 SLOAD
MyTokenname370———pure,不读 storage
MiniSwapswap27,61359,61560,17080,795收款 + 算价 + 改储备 + 转出 + 事件
MiniSwapgetAmountOut5,8345,8345,8345,834只读,前端报价用
MiniSwapaddLiquidity24,597112,304115,233118,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 池
Sepolia1 ETH = 1000 USDT = 10000 MTK0.5 ETH / 500 USDT0.5 ETH / 5000 MTK500 USDT / 5000 MTK
Nile1 TRX = 0.3 USDT = 3 MTK100 TRX / 30 USDT100 TRX / 300 MTK30 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%

从这个例子能学到三件事:

  1. 浅池 + 大单 = 巨大的价格影响。真实世界里,把大单拆小、或去流动性更深的池子,才能拿到合理价格。
  2. 池子永远不会被买空。amountOut = y·Δx'/(x+Δx') 里分母永远比分子里的 Δx' 大,所以 amountOut 永远小于 y。就算卖 100 万 MTK,也只能无限接近、却拿不走全部 110 TRX。这是恒定乘积曲线“永不碰轴”的直接结果,也保证了池子永远能报价。
  3. 价格失衡与套利。这笔交易后,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 V2MiniSwap
谁能提供流动性任何人只有 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 最重要的安全模式之一:

  1. 检查(Checks):deadline、参数、池子存在、滑点——任何一条不满足就 revert;
  2. 生效(Effects):先修改自己的状态(_setReserves);
  3. 交互(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_NativeToToken1 ETH → 约 906.6 USDT(报价 = 实际成交 = 独立计算;Swap 事件正确;返回 false 的 USDT 照样到账)
test_Swap_TokenToNative1000 MTK → ETH
test_Swap_TokenToToken_WithFalseReturnTokenUSDT ⇄ MTK 两个方向(收 USDT 用 transferFrom、付 USDT 用返回 false 的 transfer)都能成功
保护机制test_Swap_RevertsWhenSlippageExceededminAmountOut 比报价多 1 → revert InsufficientOutput(want, want+1)
test_Swap_RevertsAfterDeadlinevm.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 拒绝 → 攻击者的 receive revert → 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-timestampswap 的 deadline 判断deadline 以分钟计,出块者只能把时间戳微调几秒,不影响判断
reentrancy-eventsswap 等函数末尾的 emit事件要记录实际成交值,只能放在转账之后;整个函数有 nonReentrant,无法重入伪造事件
non-reentrant-not-firstaddLiquidity / removeLiquidityonlyOwner 只读存储、不发起外部调用,放在前面不留重入窗口,还能让非 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 让资产可以在无人托管的情况下按公开算法兑换;代价是代码难以修改、每步都要付费、安全要求极高,且看不见链外的世界。


十三、本篇小结

  1. 合约 = 规则 + 账本:余额、储备存在合约的 storage 里,只能按代码规则修改,部署后不可更改。
  2. MyToken 很短:核心逻辑继承自 OpenZeppelin 的 ERC20,自己只定义了发行量、铸币和名称。
  3. ABI 是接口说明书:函数靠 4 字节选择器识别,参数按 32 字节编码,事件分成 topics 和 data;EVM 与 TRON 共用 ABI。
  4. 先测试、再部署:20 个 Foundry 测试不花钱;EVM 链用 forge script(委托账户加 --slow),TRON 用 paris 编译 + HTTP API 幂等部署。
  5. 转账的本质:msg.sender 的余额减少、to 的余额增加(两次 SSTORE),再发出一个 Transfer 事件。
  6. AMM 的本质:x·y=k,amountOut = y·Δx'/(x+Δx'),价格由池子比例决定,0.3% 手续费让 k 略增;价格影响 ≈ 单子占池子的比例。
  7. MiniSwap 的安全设计:滑点保护 + deadline、检查-生效-交互 + nonReentrant、余额差值不信任返回值。

下一篇预告

本篇我们站在合约内部,看清了转账和兑换在链上怎样执行。但用户不会用命令行操作——他们在网页上点按钮,在 MetaMask 或 TronLink 里确认。

下一篇《前端详解》会回答:

  • 前端怎样通过 EIP-6963 发现 MetaMask、通过 window.tron 连接 TronLink?
  • ethers 和 tronWeb 如何拿着本篇导出的 ABI 和地址查余额、发起转账?
  • 兑换面板如何调用 getAmountOut 报价、计算价格影响和 minAmountOut、完成“授权并兑换”?
  • 交易发出后,页面如何通过后端推送实时更新记录?