系列导航
| 顺序 | 文章 | 定位 |
|---|---|---|
| 1 | 零代码,建立整体认识 | |
| 2 | 📍当前:智能合约:从零看懂一个代币合约 | 合约源码、ABI、测试、部署、转账时 EVM 里发生了什么 |
| 3 | RPC、钱包、Provider/Signer、new Contract、转账 12 步 | |
| 4 | JSON-RPC 与 WebSocket、事件订阅、确认数与重组、幂等、可靠性 |
本篇讲解项目的
contracts/目录:MTK 代币合约是什么、放在哪里、怎么写、怎么测试、怎么部署,以及一笔链上转账在合约里是怎样一步步执行的。 文中代码均摘自本项目源码;命令输出均为本项目实测结果(2026-09,本地 anvil 测试链与 Sepolia 测试网)。
读完本篇你将学会
- 智能合约在一个 Web3 App 里负责什么、不负责什么。
- 看懂一个 40 多行的 ERC20 代币合约,以及它继承的 OpenZeppelin 核心代码。
- 理解 ABI:它是什么、从哪来、如何把"函数名 + 参数"编码成链能识别的字节。
- 用 Foundry 测试和部署合约,并知道合约地址是怎么算出来的。
- 跟着一笔真实交易,看清转账时 EVM 里每一步做了什么、花了多少 Gas。
前置知识
建议先读第 1 篇《Web3 链上转账完整流程(小白版)》。本篇默认你已经知道这些概念:
- 钱包、地址、私钥:钱包保管私钥,用私钥"签字"发交易;地址是 0x 开头的账号。
- 区块链、智能合约:公共账本,以及部署在上面的一段程序。
- Gas:在链上执行操作要付的手续费。
- 原生币与代币:ETH 是链自带的原生币;MTK 是由合约记账的代币。
另外,懂一点编程即可,不需要学过 Solidity。代码里用到的语法会在讲到时解释。
目录
- 合约是什么:它在 App 里负责什么
- 开发工具:目录结构与 Foundry
- 源码逐段讲解:MyToken.sol
- ABI:合约的"接口说明书"
- 测试:不上链也能验证合约
- 部署:把合约放到链上
- 核心:转账时合约里发生了什么
- Gas:每一步花了多少钱
- 动手 Demo:只用命令行完成一次转账
- 常见问题
- 本篇小结
- 下一篇预告
一、合约是什么:它在 App 里负责什么
这一章要解决的问题:在读任何代码之前,先弄清合约在整个系统里的位置和职责。
1.1 一句话定位:规则 + 账本
合约 = 规则 + 账本。 它是整个 App 里唯一保存"谁有多少 MTK"的地方,也是唯一能修改余额的地方。
打个比方:合约就像一家银行的"规章制度 + 总账本",而且这本账本放在所有人都能看到的地方,谁都没法私下涂改。
下图是合约与前端、后端的关系。前端通过节点给合约发交易、查余额;后端监听合约发出的事件:
┌───────────────── 区块链(anvil / Sepolia)─────────────────┐
│ │
前端 ──签名交易──▶ RPC 节点 ──▶ EVM 执行 MyToken 合约 ──▶ 改账本 + 发 Transfer 事件
│ ▲ │
│ │ eth_call(只读,免费) │
前端 ──查余额────────────────────────────┘ │
│ │
后端 ◀──────────────── 订阅 / 查询 Transfer 事件 ─────────────────────────┘
└──────────────────────────────────────────────────────────┘
图中几个新词:
- EVM(Ethereum Virtual Machine,以太坊虚拟机):每个节点里运行合约代码的"虚拟电脑"。
- RPC 节点:前端和后端访问区块链的入口,第 3 篇会详细讲。
- eth_call:只读调用,不上链、不花钱。
- 事件(event):合约执行时留下的一条"通知",专门给链外程序看。
1.2 与 Web2 后端的对比
如果你写过传统后端,可以这样对照理解:
| 对比 | Web2(传统后端) | Web3(智能合约) |
|---|---|---|
| 余额存在哪 | 公司自己的 MySQL | 合约的 storage,全网节点各存一份 |
| 谁能改余额 | 有数据库权限的人都能改 | 只能按合约代码的规则改,部署者也不能绕过 |
| 代码能否修改 | 随时发版 | 部署后不可修改(想改只能部署新合约) |
| 谁来运行 | 公司的服务器 | 全网所有节点,每个节点都执行一遍并得到相同结果 |
| 调用要钱吗 | 不要 | 读免费;写要付 Gas |
storage(存储):合约自己的永久数据区,保存在链上。可以把它理解成"合约专属的数据库",只不过全网每个节点都存了一份。
1.3 合约负责什么、不负责什么
| 合约负责 | 合约不负责 |
|---|---|
| 保存每个地址的余额 | 用户昵称(后端数据库负责) |
| 校验转账是否合法(余额够不够) | 交易历史列表(后端从事件中整理) |
| 执行转账、修改余额 | 页面展示(前端负责) |
| 发出 Transfer 事件,通知外界 | 管理私钥、签名(钱包负责) |
为什么昵称、交易列表不放在合约里?因为写 storage 是 EVM 里最贵的操作,每存一个字都要付 Gas。所以链上只存"必须可信"的最小状态,其余都放到链下。
小结:合约只做一件事——按规则维护余额账本,并在每次变动时发出事件。展示、昵称、历史列表都交给前端和后端。
二、开发工具:目录结构与 Foundry
这一章要解决的问题:合约代码放在哪、用什么工具编译、测试和部署。
2.1 目录结构
contracts/
├── src/MyToken.sol # ★ 合约源码
├── test/MyToken.t.sol # 合约测试(也是用 Solidity 写的)
├── script/Deploy.s.sol # 部署脚本(anvil / Sepolia 共用)
├── export-abi.sh # 把 ABI 和部署信息导出给前端,并打印后端配置
├── foundry.toml # 编译器版本、优化器、RPC 端点
├── foundry.lock # 依赖版本锁定
├── lib/ # 依赖(git 子模块)
│ ├── forge-std/ # 测试 / 脚本工具库
│ └── openzeppelin-contracts/# OpenZeppelin v5.7.0:业界标准的 ERC20 实现
├── out/ # 编译产物(字节码 + ABI),不提交
└── broadcast/ # 部署记录:31337(anvil)/ 11155111(Sepolia)
最重要的只有三个文件:src/MyToken.sol(合约本身)、test/MyToken.t.sol(测试)、script/Deploy.s.sol(部署)。31337 和 11155111 是链 ID(chain ID),分别代表本地 anvil 链和 Sepolia 测试网。
2.2 Foundry 工具链
本项目使用 Foundry 工具链。它是一套用 Rust 写的合约开发工具,包含 4 个命令行程序:
| 工具 | 作用 | 本项目中的用法 |
|---|---|---|
forge | 编译、测试、部署 | forge test、forge script |
cast | 在命令行里和链交互 | cast call 查余额、cast send 发交易、cast receipt 看回执 |
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 读取后传入
- solc:Solidity 编译器,把
.sol源码编译成 EVM 能执行的字节码(bytecode)。 - 优化器(optimizer):在编译时精简字节码,目的是省 Gas。
三、源码逐段讲解: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 元纸币没有区别。只要合约实现了下面这些函数和事件,任何钱包、交易所、区块浏览器都能识别它:
| 类型 | 名称 | 作用 | 读 / 写 |
|---|---|---|---|
| 函数 | name() / symbol() / decimals() | 代币名称、符号、小数位 | 读 |
| 函数 | totalSupply() | 总发行量 | 读 |
| 函数 | balanceOf(address) | 查某地址的余额 | 读 |
| 函数 | transfer(to, value) | 转账(本项目核心) | 写 |
| 函数 | approve / allowance / transferFrom | 授权别人代为转账(本项目未使用) | 写 / 读 |
| 事件 | Transfer(from, to, value) | 每次余额变动都要发出 | — |
| 事件 | Approval(owner, spender, value) | 授权时发出 | — |
is ERC20 就是"继承",和面向对象语言里的 extends 一样。OpenZeppelin 是业界最常用的合约库,代码经过大量审计。继承它之后,上面这些都"免费"获得了。OpenZeppelin 内部维护着真正的账本:
// OpenZeppelin ERC20.sol(v5.7.0)内部状态
mapping(address account => uint256) private _balances; // 地址 → 余额
uint256 private _totalSupply; // 总量
mapping 就是链上的"数据库表":key 是持币人地址,value 是余额。它可以类比 JavaScript 里的 Map<address, bigint>。它存放在合约的 storage 里,而 storage 会永久保存在链上。
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 的最小单位更小,1 MTK = 10¹⁸ 个最小单位。
所以数据库和链上存的 1000000000000000000000000 就是 100 万 MTK。页面显示时再除以 10¹⁸。
constant(常量)的好处:编译时直接写进字节码,不占 storage,读取时也不花 SLOAD 的 Gas。SLOAD 是 EVM 里"从 storage 读一个值"的指令,要收费;对应的写入指令叫 SSTORE,更贵。
3.3 constructor:部署时执行一次
constructor() ERC20("", "") {
_mint(msg.sender, INITIAL_SUPPLY);
}
- 构造函数(constructor)只在部署时执行一次,之后永远不会再执行。
msg.sender是"当前这次调用是谁发起的"。部署时就是发起部署交易的地址,也就是部署者。Sepolia 上是"部署者和持币人"0x83Da…。_mint(铸造) 会把 100 万 MTK 记到部署者名下,并发出事件Transfer(0x000…000, 部署者, 100万)。- from 是零地址,表示"凭空产生"。
- 这就是数据库 transactions 表第一条记录"铸造"的来源。
3.4 name() / symbol():一个 Gas 优化
OpenZeppelin 的默认做法是把名称写进 storage:ERC20("MyToken", "MTK")。本项目改为:
- 给父构造函数传空字符串,名称不写 storage,部署时省下约 4 万 Gas。
- 重写(
override)name()、symbol(),直接返回字符串常量。它们是pure函数:不读也不写任何链上状态。
想把代币改名叫 Dog?改这两个函数的返回值,然后重新部署。已部署的合约永远改不了,新部署会得到一个新地址。
小结:MyToken 自己只写了三件事——初始发行量、部署时铸币给部署者、返回名称和符号。余额账本、转账逻辑全部来自 OpenZeppelin 的 ERC20。
四、ABI:合约的"接口说明书"
这一章要解决的问题:合约部署到链上之后,外界要怎么调用它?答案是 ABI。
本章讲清楚 ABI 是什么、长什么样、如何完成编码与解码。前端如何用 ABI 创建合约对象(new Contract),见第 3 篇的「用 ABI 调用合约:new Contract」一节。
4.1 ABI 到底是什么
ABI(Application Binary Interface,应用二进制接口) 是一份 JSON 格式的"说明书",描述了一个合约有哪些函数、每个函数的参数和返回值是什么类型、会发出哪些事件、可能抛出哪些错误。
为什么需要它?因为部署到链上的只有字节码,也就是一串机器指令。下面是 Sepolia 上 MTK 合约的真实字节码开头(用 cast code 查询,全长 2154 字节):
0x608060405234801561000f575f5ffd5b506004361061009f575f3560e01c8063...
字节码里没有函数名、没有参数名、没有类型信息。EVM 只认"calldata 前 4 个字节是多少,就跳到哪段指令执行"。
calldata(调用数据) 是交易里附带的一串字节,写明"要调用哪个函数、传什么参数"。类比:它就是 HTTP 请求的 body,只不过是二进制格式。
如果没有 ABI,前端根本不知道:
- 要查余额,该往 calldata 里写什么字节?
- 节点返回的
0x…14542ba12a337c00000是一个数字、一个地址,还是一个字符串? - 交易日志里
topics[1]那 32 个字节代表什么?
用 Web2 的概念来类比:
| Web2 | Web3 |
|---|---|
| 后端服务 | 部署在链上的合约(字节码) |
| OpenAPI / Swagger 文档:有哪些接口、参数、返回值 | ABI:有哪些函数、参数、返回值、事件、错误 |
TypeScript 的 .d.ts 类型声明 | ABI(再配合 TypeChain 可以生成 TS 类型) |
| axios 按接口文档拼 URL 和 body | ethers 按 ABI 编码 calldata |
4.2 ABI 从哪里来
ABI 由 Solidity 编译器根据合约源码自动生成,不需要手写:
contracts/src/MyToken.sol
│ forge build(solc 编译)
▼
contracts/out/MyToken.sol/MyToken.json ← 编译产物:字节码 + ABI + 其他元数据
│ export-abi.sh:jq '.abi' 提取出 ABI 部分
▼
frontend/src/abi/MyToken.abi.json ← 前端 import 的就是这个文件(5.7 KB)
本项目 ABI 一共 18 个条目。其中大部分来自继承的 OpenZeppelin ERC20,自己写的 MyToken.sol 只重写了 name 和 symbol:
| 类型 | 数量 | 条目 |
|---|---|---|
function | 9 | allowance、approve、balanceOf、decimals、name、symbol、totalSupply、transfer、transferFrom |
event | 2 | Approval、Transfer |
error | 6 | ERC20InsufficientAllowance、ERC20InsufficientBalance、ERC20InvalidApprover、ERC20InvalidReceiver、ERC20InvalidSender、ERC20InvalidSpender |
constructor | 1 | 构造函数(无参数) |
4.3 一个真实的 ABI JSON 片段
下面 4 个条目摘自本项目的 frontend/src/abi/MyToken.abi.json,未做任何修改。它们分别是一个读函数、一个写函数、一个事件和一个错误:
[
{
"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": "error",
"name": "ERC20InsufficientBalance",
"inputs": [
{ "name": "sender", "type": "address", "internalType": "address" },
{ "name": "balance", "type": "uint256", "internalType": "uint256" },
{ "name": "needed", "type": "uint256", "internalType": "uint256" }
]
}
]
字段逐个解读:
| 字段 | 含义 | 例子 |
|---|---|---|
type | 条目类型:function / event / error / constructor / fallback / receive | "function" |
name | 函数、事件、错误的名字 | "transfer" |
inputs | 参数列表:每个参数的名字和 ABI 类型 | to: address、value: uint256 |
outputs | 返回值列表(只有函数有) | bool |
internalType | Solidity 源码里的原始类型(例如结构体名),供工具参考,编码时不用 | "address" |
stateMutability | 函数会不会改链上状态,见下表 | "view" |
indexed | (仅事件)该参数是否放在 topics 里,可被节点高效过滤 | from、to 为 true |
anonymous | (仅事件)为 true 时不把事件签名放进 topics[0] | false |
stateMutability(状态可变性)决定了 ethers 怎么调用这个函数:
| 值 | 含义 | 本项目中的函数 | ethers 的调用方式 |
|---|---|---|---|
pure | 不读也不写链上状态 | name、symbol | eth_call,免费 |
view | 只读链上状态 | balanceOf、decimals、totalSupply、allowance | eth_call,免费 |
nonpayable | 会修改状态,不能附带 ETH | transfer、approve、transferFrom | 发交易,要签名、付 Gas |
payable | 会修改状态,可以附带 ETH | 本项目没有 | 发交易,可带 value |
一句话记忆:pure / view 是"查询",免费;nonpayable / payable 是"办业务",要签名付费。
4.4 ABI 的两大作用:编码与解码
ABI 本身不能执行任何操作。它的价值在于让程序知道怎么把数据翻译成链能听懂的字节(编码),以及怎么把链返回的字节翻译回来(解码)。
下面用 5 个演示逐一说明。以下输出均由前端项目安装的 ethers 6.17.0(Interface 类)实际运行得到。
① 从 ABI 算出"选择器":链上认的是这 4 个字节,而不是函数名
把 ABI 条目拼成签名字符串(名字 + 参数类型,不含参数名和空格),做 keccak256 哈希。
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])
用 Foundry 的 cast 也能算出同样的结果:
$ cast sig "transfer(address,uint256)"
0xa9059cbb
$ cast keccak "Transfer(address,address,uint256)"
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
后端订阅时写的过滤条件"合约地址 = MTK,topics[0] = 0xddf252ad…",意思就是"只要 MTK 合约的 Transfer 事件"。
② 编码调用:函数名 + 参数 → calldata
iface.encodeFunctionData("transfer", ["0x455b…d6a0", 2500n * 10n ** 18n]);
0xa9059cbb ← 选择器
000000000000000000000000455bddc544682651f75af54a0c2124289993d6a0 ← to:address 左侧补零到 32 字节
0000000000000000000000000000000000000000000000878678326eac900000 ← value:uint256,2500 × 10¹⁸
规律:选择器 4 字节在前,后面每个参数各占 32 字节。
③ 解码返回值:返回的字节 → JS 值
iface.decodeFunctionResult("balanceOf", "0x…014542ba12a337c00000");
// → 6000000000000000000000n → 6000.0 MTK
ABI 告诉 ethers "balanceOf 返回一个 uint256",它才知道要把这 32 字节解析成大整数,而不是地址或字符串。
④ 解析事件日志:topics + data → 事件对象
用 Sepolia 上"收款人1 转 2500 MTK 给收款人2"的真实日志:
iface.parseLog({
topics: ["0xddf252ad…", "0x…f78c9972…", "0x…455bddc5…"],
data: "0x…878678326eac900000",
});
Transfer {
from: '0xF78c997295AE31E35a4E9dEbAA773D1Df9F74Bf2',
to: '0x455BdDC544682651F75aF54a0c2124289993D6a0',
value: 2500000000000000000000n
}
ABI 里的 indexed: true 告诉 ethers:from、to 在 topics[1]、topics[2] 里,value 在 data 里。
⑤ 解析 revert 错误:错误数据 → 错误名 + 参数
revert(回滚) 是合约主动"拒绝执行":整笔交易作废,并返回一段错误数据。合约因为余额不足 revert 时,返回的错误数据长这样:
0xe450d38c ← ERC20InsufficientBalance 的选择器
000000000000000000000000f78c997295ae31e35a4e9debaa773d1df9f74bf2 ← sender
0000000000000000000000000000000000000000000000000000000000000000 ← balance = 0
0000000000000000000000000000000000000000000000000000000000000001 ← needed = 1
iface.parseError(errData); // → ERC20InsufficientBalance [ '0xF78c…4Bf2', 0n, 1n ]
这就是前端能写 e.revert?.name === 'ERC20InsufficientBalance' 的原因(见第 3 篇的「错误处理」一节):ABI 里带着 error 条目,ethers 才能把 0xe450d38c 认出来。
4.5 ABI 必须与合约一致
如果 ABI 里声明了一个合约里不存在的函数,ethers 照样能编码出 calldata,但链上合约找不到这个选择器,调用就会失败(真实运行结果):
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
同理,参数类型写错会算出不同的选择器(比如把 uint256 写成 uint128),同样会调用失败。所以 ABI 应该从编译产物中导出,不要手抄。
合约重新部署后要重新导出 ABI。 只改了实现、接口没变时,ABI 不变,只有地址变了;如果增减了函数,就必须更新 ABI。本项目的 make deploy-* 每次都会自动执行 export-abi.sh,同时更新 ABI 和地址。
4.6 合约、前端、后端如何使用 ABI
| 角色 | 如何使用 ABI | 位置 |
|---|---|---|
| 合约 | 编译时生成 ABI | contracts/out/MyToken.sol/MyToken.json |
| 前端 | 导入完整的 ABI JSON,交给 ethers 编码调用、解码结果和错误 | frontend/src/abi/MyToken.abi.json |
| 后端 | 没有导入 ABI 文件,只手写了用到的事件签名,自己算出 topics[0],并按 indexed 规则手动解析 topics 和 data | backend/listener/listener.go 的 transferTopic、saveLog |
| Etherscan 等浏览器 | 合约源码验证后拿到 ABI,才能显示 "Method: Transfer"、解码日志 | 链上浏览器 |
后端只关心一个事件,手写更简单直观。如果要用到很多函数和事件,Go 可以用 go-ethereum 自带的 abigen 工具,从 ABI 生成类型安全的 Go 代码。
小结:ABI 是合约的接口说明书,由编译器自动生成。链上只认字节:函数靠 4 字节选择器区分,参数按 32 字节一格排列,事件靠 topics 和 data 传递。ABI 负责在"人能读的名字"和"链能读的字节"之间来回翻译。
五、测试:不上链也能验证合约
这一章要解决的问题:合约部署后改不了,所以上链前必须先确认它没有 bug。
Foundry 的测试也用 Solidity 写,运行在本地的 EVM 里,不需要任何链、不花任何钱。
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);
}
// 转账必须发出 Transfer 事件:from、to、value 正是后端要解析的三个字段
function test_TransferEmitsEvent() public {
vm.expectEmit(true, true, false, true, address(token));
emit Transfer(alice, bob, 1e18);
vm.prank(alice);
token.transfer(bob, 1e18);
}
// 余额不足必须失败,并带上 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 运行结果(真实输出)
$ make test # 等价于 cd contracts && forge test -vv
Ran 6 tests for test/MyToken.t.sol:MyTokenTest
[PASS] testFuzz_TransferConservesBalance(uint256) (runs: 256, μ: 92953, ~: 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
6 个测试全部通过。其中 test_InitialSupplyGoesToDeployer 和 test_Metadata 没有出现在上面的节选里,它们分别检查"初始发行量归部署者"和"名称、符号、小数位正确"。
六、部署:把合约放到链上
这一章要解决的问题:测试通过后,怎样把合约发布到链上,以及前端、后端怎样拿到合约地址。
6.1 部署脚本 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));
}
}
同一份脚本部署到两条链,区别只在命令行参数:
| anvil | Sepolia | |
|---|---|---|
| 命令 | make deploy-local | make estimate-sepolia 先预估,再 make deploy-sepolia |
| RPC | --rpc-url anvil | --rpc-url sepolia(从 backend/.env 读取 Infura 地址) |
| 签名方式 | --private-key anvil 公开的 0 号测试私钥 | --account deployer:Foundry 加密保存的私钥,需输入密码 |
| 花费 | 0(测试币) | 实测约 75.5 万 Gas,约 0.0014~0.0017 SepoliaETH |
6.2 部署时链上发生了什么
部署本质上也是一笔交易,只是比较特殊:
- forge 把
new MyToken()编译成一笔特殊交易:to为空,data是合约字节码。 - 用部署者私钥签名后,通过 RPC 广播。
- 验证者把交易打包进区块,EVM 执行:
- 按公式算出合约地址;
- 执行 constructor,把 100 万 MTK 铸造给部署者,发出 Transfer 事件;
- 把合约的运行时字节码永久存到这个地址上。
- 交易回执(receipt)里带回
contractAddress,forge 把它保存到broadcast/Deploy.s.sol/<chainId>/run-latest.json。
交易回执(receipt) 是交易执行完之后链给出的"结果单",记录了是否成功、消耗多少 Gas、产生了哪些日志等。
6.3 合约地址是怎么算出来的
合约地址不是随机的,由"部署者地址 + 部署者当时的 nonce"决定。
nonce 是一个账户已经发出的交易数量,从 0 开始计数。第 1 笔交易的 nonce 是 0,第 2 笔是 1,以此类推。类比:支票簿上的流水号,每用一张加 1。
合约地址 = keccak256(rlp([部署者地址, nonce])) 的后 20 字节
其中 rlp 是以太坊的一种数据序列化格式,可以理解为"把两个值按固定规则拼成一串字节"。
本项目的真实数据:
$ 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 部署完成后:export-abi.sh 把结果交给前后端
make deploy-* 最后会执行 ./export-abi.sh <chainId>,它做三件事:
# 1) 导出 ABI 给前端
jq '.abi' out/MyToken.sol/MyToken.json > ../frontend/src/abi/MyToken.abi.json
# 2) 从部署回执里取出合约地址、部署区块、部署者,写入 deployments.json(多条链合并在一个文件里)
ADDRESS=$(jq -r '.receipts[] | select(.contractAddress != null) | .contractAddress' "$RUN" | head -1)
BLOCK_HEX=$(jq -r '.receipts[] | select(.contractAddress != null) | .blockNumber' "$RUN" | head -1)
OWNER=$(jq -r '.transactions[0].transaction.from' "$RUN")
# 3) 打印后端需要的配置
SEPOLIA_CONTRACT=0xcbee6a188946136b460c833b238eb5665e1f2447
SEPOLIA_START_BLOCK=11784949
最终生成的 frontend/src/abi/deployments.json:
{
"31337": {
"address": "0xdc64a140aa3e981100a9beca4e685f962f0cf6c9",
"startBlock": 517,
"owner": "0xf39fd6e5…"
},
"11155111": {
"address": "0xcbee6a188946136b460c833b238eb5665e1f2447",
"startBlock": 11784949,
"owner": "0x83da6626…"
}
}
三方各取所需:
- 前端读
address,知道去哪个合约查余额、转账。 - 后端用
SEPOLIA_CONTRACT和SEPOLIA_START_BLOCK,知道监听哪个合约、从哪个区块开始同步。 make seed-sepolia读owner,把部署者以"合约持有人"的昵称写进 users 表。
小结:部署 = 发一笔
to为空、data为字节码的交易。合约地址由部署者地址和 nonce 决定。部署完成后,export-abi.sh把 ABI、地址和起始区块交给前端和后端。
七、核心:转账时合约里发生了什么
这一章要解决的问题:用户点下"转账"并在钱包里确认之后,交易进入合约,EVM 里到底一步步做了什么。
本章以 Sepolia 上的一笔真实交易为例:勇敢的鹦鹉(收款人1)转 2500 MTK 给迷糊的水獭(收款人2)。
交易哈希:0x80e152bb556f0d778c136a9461f75cbd080b6a7ebd077a02f2a309f97394f3a5
关于步骤编号:本系列把一次转账拆成连续编号的多个步骤。前面的步骤(编码 calldata、钱包签名、广播、打包等)发生在前端、钱包和节点上,完整的 22 步时序图见第 3 篇「链上转账:前端的 12 个步骤」一章;本章从交易进入合约开始,编号为 ⑪~⑮,与那张图一一对应。
第 ⑪ 步:EVM 根据函数选择器找到 transfer
交易到达合约后,EVM 读取 calldata 前 4 字节 0xa9059cbb,跳转到 transfer 函数,并把后面的 64 字节解码成 to 和 value。
此时 msg.sender = 签名者 = 收款人1 0xf78c…。合约只认 msg.sender:你只能转自己的钱,因为只有你的私钥能让 msg.sender 等于你的地址。
第 ⑫ 步:检查参数和余额
OpenZeppelin 的源码(v5.7.0,lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol):
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 照样扣,因为节点已经帮你算过了。前端会把这个错误翻译成"合约拒绝:MTK 余额不足"。
第 ⑬ 步:修改账本(真正的"转账")
unchecked {
_balances[from] = fromBalance - value; // SSTORE:转出方减少
}
...
unchecked {
_balances[to] += value; // SSTORE:转入方增加
}
- 这两次
SSTORE(写 storage)才是真正意义上的"转账":账本上一边减、一边加。 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)= 2500000000000000000000 = 2500 MTK |
logIndex | 0x3f = 63 | 这条日志在整个区块里排第 63 个 |
为什么 from 和 to 要声明成
indexed? 放在 topics 里的参数可以被节点高效过滤,例如"只查转给某地址的记录"。类比:数据库里给某一列建了索引。后端的saveLog就是按这张表解析的。
第 ⑮ 步:交易执行完毕,生成回执
回执中 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:每一步花了多少钱
这一章要解决的问题:上面每一步各花多少 Gas,哪些操作贵、为什么贵。
make gas(forge test --gas-report)的真实输出:
| 函数 | 最小 | 平均 | 中位数 | 最大 | 说明 |
|---|---|---|---|---|---|
transfer | 24,364 | 50,232 | 51,483 | 51,519 | 写操作,两次 SSTORE |
balanceOf | 2,558 | 2,558 | 2,558 | 2,558 | 读操作,一次 SLOAD |
totalSupply | 2,325 | 2,325 | 2,325 | 2,325 | 读操作 |
name | 370 | — | — | — | pure,不读 storage |
symbol | 402 | — | — | — | pure,不读 storage |
几个规律:
- 读函数通过
eth_call调用时完全免费。上表的数字只是它们在交易内部被调用时的成本。 - transfer 的主要开销是 SSTORE(写 storage):
- 把一个 storage 槽从 0 写成非 0(对方第一次收到 MTK)约 2 万 Gas,这是最贵的情况。
- 从非 0 改成非 0 约 3 千 Gas。
- 所以"首次转给某人"比"再次转给他"贵。
name、symbol只要 300 多 Gas,是 3.4 节那个优化的效果。- 加上每笔交易固定的 21,000 基础费,一次普通转账实际消耗约 5 万 Gas。
为什么写 storage 这么贵?因为写进 storage 的数据要被全网每个节点永久保存,所以链用高价来限制大家随意存数据。
九、动手 Demo:只用命令行完成一次转账
这一章要解决的问题:不用页面、不用 MetaMask,亲手走一遍"查余额 → 转账 → 看事件"。
用 cast 就能直接和合约交互。以下命令在 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 步后,如果后端正在运行,它的日志会立刻出现:
[anvil] 收到推送:区块 2797,交易 0xc4ad58f8…,等待 0 个确认后入库
[anvil] 同步区块 2795→2797,事件 1 条,新写入 1 条
页面列表里也会出现这笔记录。这说明后端只关心链上的事件,不关心转账是从哪里发起的。
十、常见问题
Q:合约部署后发现 bug 怎么办? 改不了。只能修复代码后重新部署一个新合约(新地址),并更新前后端配置。生产环境会用"可升级代理合约"模式来解决这个问题,本项目为了简单没有采用。
Q:我的 MTK 存在钱包里吗?
不在。存在 MTK 合约的 _balances 里。钱包只保管私钥,"导入代币"只是让钱包去合约查余额并显示出来。
Q:转账失败了为什么还扣手续费? 节点已经执行了你的交易,直到 revert 那一刻消耗的计算都要付费。这也是为什么前端要先做表单校验(余额够不够),尽量不发注定失败的交易。
Q:为什么 transfer 返回 bool,却几乎总是 true?
这是 ERC20 标准的历史设计。OpenZeppelin v5 在失败时直接 revert,而不是返回 false,所以只要执行成功就一定是 true。
Q:anvil 上为什么部署过两次(两个合约地址)?
每次执行 make deploy-local 都会部署一个全新的合约。两个合约各有独立的 100 万 MTK,互不相干。后端和前端只使用配置中的那一个(0xdc64…)。
十一、本篇小结
- 合约 = 规则 + 账本:余额存在合约的 storage 里,只能按代码规则修改,部署后不可更改。
- MyToken 很短:核心逻辑继承自 OpenZeppelin 的 ERC20,自己只定义了发行量、铸币和名称。
- ABI 是接口说明书:函数靠 4 字节选择器识别,参数按 32 字节编码,事件分成 topics 和 data。
- 先测试、再部署:Foundry 测试不花钱;合约地址由部署者地址和 nonce 决定。
- 转账的本质:
msg.sender的余额减少、to的余额增加(两次 SSTORE),再发出一个 Transfer 事件。
下一篇预告
本篇我们站在合约内部,看清了一笔转账在 EVM 里怎样执行。但用户不会用命令行转账——他们在网页上点按钮,在 MetaMask 里确认。
下一篇《前端:用 MetaMask + ethers 完成链上转账》会回答:
- 前端怎样通过 RPC 节点和 MetaMask 与链通信?
- ethers 的 Provider 和 Signer 分别是什么?
- 拿着本篇导出的 ABI 和合约地址,
new Contract之后如何查余额、发起转账? - 从用户点击"转账"到交易上链,前端经历的 12 个步骤。