系列导航

顺序文章定位
1Web3 链上转账完整流程(小白版)零代码,建立整体认识
2📍当前:智能合约:从零看懂一个代币合约合约源码、ABI、测试、部署、转账时 EVM 里发生了什么
3前端:用 MetaMask + ethers 完成链上转账RPC、钱包、Provider/Signer、new Contract、转账 12 步
4后端:监听链上事件并同步到数据库JSON-RPC 与 WebSocket、事件订阅、确认数与重组、幂等、可靠性

本篇讲解项目的 contracts/ 目录:MTK 代币合约是什么、放在哪里、怎么写、怎么测试、怎么部署,以及一笔链上转账在合约里是怎样一步步执行的。 文中代码均摘自本项目源码;命令输出均为本项目实测结果(2026-09,本地 anvil 测试链与 Sepolia 测试网)。

读完本篇你将学会

  1. 智能合约在一个 Web3 App 里负责什么、不负责什么。
  2. 看懂一个 40 多行的 ERC20 代币合约,以及它继承的 OpenZeppelin 核心代码。
  3. 理解 ABI:它是什么、从哪来、如何把"函数名 + 参数"编码成链能识别的字节。
  4. 用 Foundry 测试和部署合约,并知道合约地址是怎么算出来的。
  5. 跟着一笔真实交易,看清转账时 EVM 里每一步做了什么、花了多少 Gas。

前置知识

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

  • 钱包、地址、私钥:钱包保管私钥,用私钥"签字"发交易;地址是 0x 开头的账号。
  • 区块链、智能合约:公共账本,以及部署在上面的一段程序。
  • Gas:在链上执行操作要付的手续费。
  • 原生币与代币:ETH 是链自带的原生币;MTK 是由合约记账的代币。

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


目录

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

一、合约是什么:它在 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
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 读取后传入
  • 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 的概念来类比:

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

类型数量条目
function9allowance、approve、balanceOf、decimals、name、symbol、totalSupply、transfer、transferFrom
event2Approval、Transfer
error6ERC20InsufficientAllowance、ERC20InsufficientBalance、ERC20InvalidApprover、ERC20InvalidReceiver、ERC20InvalidSender、ERC20InvalidSpender
constructor1构造函数(无参数)

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
internalTypeSolidity 源码里的原始类型(例如结构体名),供工具参考,编码时不用"address"
stateMutability函数会不会改链上状态,见下表"view"
indexed(仅事件)该参数是否放在 topics 里,可被节点高效过滤from、to 为 true
anonymous(仅事件)为 true 时不把事件签名放进 topics[0]false

stateMutability(状态可变性)决定了 ethers 怎么调用这个函数:

值含义本项目中的函数ethers 的调用方式
pure不读也不写链上状态name、symboleth_call,免费
view只读链上状态balanceOf、decimals、totalSupply、allowanceeth_call,免费
nonpayable会修改状态,不能附带 ETHtransfer、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位置
合约编译时生成 ABIcontracts/out/MyToken.sol/MyToken.json
前端导入完整的 ABI JSON,交给 ethers 编码调用、解码结果和错误frontend/src/abi/MyToken.abi.json
后端没有导入 ABI 文件,只手写了用到的事件签名,自己算出 topics[0],并按 indexed 规则手动解析 topics 和 databackend/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));
    }
}

同一份脚本部署到两条链,区别只在命令行参数:

anvilSepolia
命令make deploy-localmake 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 部署时链上发生了什么

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

  1. forge 把 new MyToken() 编译成一笔特殊交易:to 为空,data 是合约字节码。
  2. 用部署者私钥签名后,通过 RPC 广播。
  3. 验证者把交易打包进区块,EVM 执行:
    • 按公式算出合约地址;
    • 执行 constructor,把 100 万 MTK 铸造给部署者,发出 Transfer 事件;
    • 把合约的运行时字节码永久存到这个地址上。
  4. 交易回执(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"
}

逐项解读:

字段值含义
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)= 2500000000000000000000 = 2500 MTK
logIndex0x3f = 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)的真实输出:

函数最小平均中位数最大说明
transfer24,36450,23251,48351,519写操作,两次 SSTORE
balanceOf2,5582,5582,5582,558读操作,一次 SLOAD
totalSupply2,3252,3252,3252,325读操作
name370———pure,不读 storage
symbol402———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…)。


十一、本篇小结

  1. 合约 = 规则 + 账本:余额存在合约的 storage 里,只能按代码规则修改,部署后不可更改。
  2. MyToken 很短:核心逻辑继承自 OpenZeppelin 的 ERC20,自己只定义了发行量、铸币和名称。
  3. ABI 是接口说明书:函数靠 4 字节选择器识别,参数按 32 字节编码,事件分成 topics 和 data。
  4. 先测试、再部署:Foundry 测试不花钱;合约地址由部署者地址和 nonce 决定。
  5. 转账的本质:msg.sender 的余额减少、to 的余额增加(两次 SSTORE),再发出一个 Transfer 事件。

下一篇预告

本篇我们站在合约内部,看清了一笔转账在 EVM 里怎样执行。但用户不会用命令行转账——他们在网页上点按钮,在 MetaMask 里确认。

下一篇《前端:用 MetaMask + ethers 完成链上转账》会回答:

  • 前端怎样通过 RPC 节点和 MetaMask 与链通信?
  • ethers 的 Provider 和 Signer 分别是什么?
  • 拿着本篇导出的 ABI 和合约地址,new Contract 之后如何查余额、发起转账?
  • 从用户点击"转账"到交易上链,前端经历的 12 个步骤。