🎬 开场 · 先读,再写
上一课结尾我们说”下一课开始写它”——但更诚实的版本是:写之前得先读得懂。
ETH 上每个 verified 合约的源码都是公开的。你想在 DeFi 里赚钱、想审一段陌生合约的安全性、想看看 Uniswap 到底干了什么——任何严肃的链上动作前都得先把别人的代码读懂。这一课就教会你怎么读。
👋 先碰一下 · 智能合约到底是个什么东西
先别管语法。下面这就是一份完整的、能跑的售货机智能合约。先动手玩一遍,感受一下:写一段代码 → 部署上链 → 调用它——这整个流程,就是”智能合约”。
试试这三步:
- 点 ▶ compile ——把 Solidity 源码编译成 EVM 字节码
- 点 ⬢ deploy ——把字节码作为一笔 transaction 上链,生成一个合约地址
- 在 functions 区找到 buySoda,value 输
0.001 ether,点 ▶ ——花 0.001 ETH 买一瓶汽水,sodaCount从 100 减到 99- 顺便再点一次 ▶ buySoda 但 value 输
0——它会 revert “send enough”,因为合约里有require检查
- 顺便再点一次 ▶ buySoda 但 value 输
刚才发生的事,用一张图说清楚——点右上角的 next → 一步步看:
◆ vending machine · lifecycle
sodaPrice = 0.001 ether;
sodaCount = 100;
function buySoda() {...}
}
chain state · what's actually on the blockchain
这就是智能合约的全部生命周期:源码 → 字节码 → 链上地址 → 永久 state,调用一次状态改一次。下面 8 节会把这份合约的每一行拆开讲——每讲完一个概念,售货机就在你眼前长出对应的那段代码。
今天的检查表
L1 用「银行职能 8 项」组装出 BTC、L2 用「世界计算机 6 个零件」组装出 ETH。这一课换一张清单——「读懂一份合约的 8 件事」:
每讲完一项,右上角浮窗自动勾掉对应一项。八项全亮,你就能读懂任何一份合约。
💡 8 项 checkoff 全是阅读——售货机合约会随着 8 项概念逐步生长,每加一项都用高亮标出新加的行。最后 F.3 有一个真正的 in-browser Solidity Playground,让你在
TipJar.sol上动手跑 compile / deploy / call / revert,把这一课验收。
模块 A · 文件头
A.1 · 售货机回归 · 这次是真 Solidity
L2 的 C.1 用 Nick Szabo 的自动售货机比喻引出智能合约。这一节我们把售货机真的写出来——从空壳开始,每讲完一个概念就在它身上加一段代码,最后它会自然长成一份带 mapping / event / custom error / fallback 的完整合约:
◆ VendingMachine.sol · v1 · 文件外壳
只有 pragma + 空 contract
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5}
这就是本课的锚点——一份会陪你一路成长的合约。每一节都会回到它、给它加点东西。新加的几行会用 ↑ accent 色 + new 标记标出来。
A.2 · SPDX + pragma · 信封外的东西
每份 Solidity 文件的前两行几乎都是相同的格式——回到售货机的 1-2 行:
◆ VendingMachine.sol · v1 · 文件外壳
只有 pragma + 空 contract
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5}
- SPDX · 软件许可声明。这一行没它编译器会报警告。常见值:
MIT/GPL-3.0/UNLICENSED - pragma · 编译器版本约束。
^0.8.20意思是「≥ 0.8.20 且 < 0.9.0」。为什么重要:不同版本的 Solidity 行为不一样(最著名的就是0.8之前没有内置整数溢出检查)
一个具体例子:同一段代码在两个版本下行为完全不同。
// pragma solidity ^0.8.20; → 编译报错 · 自动 revert
// pragma solidity ^0.4.0; → 静默回绕到 0 · 资金可能凭空消失
uint8 x = 255;
x = x + 1; // 0.8+: revert (Panic 0x11) | 0.4: x == 0
⚠️ pragma 不是装饰——它锁定了编译器的语义。读老合约(2017-2019 上线的)务必先看 pragma,再判断它有没有手写 SafeMath。
模块 B · 数据住在哪
B.1 · 状态变量 · 谁能看见
售货机里这三行就是状态变量:
◆ VendingMachine.sol · v2 · + 状态变量 + constructor
加 3 个 public 字段 · constructor 在部署时把 owner 写好
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5 address public owner;new6 uint256 public sodaPrice = 0.001 ether;new7 uint256 public sodaCount = 100;new89 constructor() {new10 owner = msg.sender;new11 }new12}
address / uint256 是类型,owner / sodaPrice 是名字,public 是可见性修饰符——决定了”谁能从合约外读到这个值”。
💡 先记住这一句就够:
public/external/internal/private这 4 个关键字,第一遍读合约时把它们看成”大体上是同一类东西”——都是”这个东西从哪里能访问”的标签。它们之间的细微差别要等到 L4 讲合约继承、合约调合约时才真正用得上。这一节看个总体感觉即可,下面表格当字典查就行:
| 可见性 | 谁能从合约外访问 | 谁能从合约内访问 | 编译器自动加 getter |
|---|---|---|---|
public | ✅ | ✅ | ✅ |
external | ✅ | ❌(要 this.fn()) | — |
internal | ❌ | ✅(含子合约) | ❌ |
private | ❌ | ✅(仅本合约) | ❌ |
用一句话概括:public 最开放,private 最封闭,external 是”只对外不对内”的偏门版本,internal 是”对自己和子孙合约友好”——它们都在回答同一个问题:“这个名字从哪里能被引用”。
⚠️ 链上没有秘密 ·
private只是阻止别的合约调用读取——任何人都能从节点的存储里看到原始数据。“私有”指代码权限,不是数据机密性。
B.2 · 简单类型 vs 复杂类型 · 大小的分水岭
一句话标尺:
- 简单类型——大小固定、能整块塞进 EVM 一个 32 字节小格子里:数字(
uint256,int256)、真假(bool)、地址(address)、固定长字节串(bytes32)、枚举(enum) - 复杂类型——大小不固定、装不下:文字(
string)、变长字节串(bytes)、数组、结构体(struct)、键值对表(mapping)
| 简单类型 | 复杂类型 |
|---|---|
uint256 / int256 | string |
bool | bytes |
address / address payable | T[] 数组 |
bytes32 / bytes1-bytes32 | struct |
enum | mapping |
一个直觉:简单类型像数字写在小纸条上,递给别人时整张纸条复印一份,对方涂改不影响你手上那张;复杂类型像一个文件柜,递给别人时只是给了对方一把钥匙,对方动了文件你这边也跟着变。
function bumpValue(uint256 n) public pure {
n = n + 1; // 改的是复印件 · 调用方的变量不动
}
function bumpRef(uint256[] storage arr) internal {
arr[0] = arr[0] + 1; // 改的是 storage 原地 · 调用方能看见变化
}
这条”复印 vs 钥匙”的差别,在计算机科学课里有专门名字——简单类型叫值类型(pass-by-value,整块复制),复杂类型叫引用类型(pass-by-reference,传指针)。现阶段不用记这两个术语,知道”复印 vs 钥匙”够用;将来读 Solidity 文档或社区讨论看到”值类型/引用类型”时,回来对照一下即可。
🔥 简单类型 · 两个让人栽过跟头的真实陷阱
陷阱 1 · uint256 溢出 · BEC 2018 蒸发 $20 亿
uint256 是范围 [0, 2²⁵⁶-1] 的非负整数。2018 年 4 月 25 日,BEC(Beauty Chain)token 的合约里有这么一行:
// 简化版 · 真实 BEC 漏洞代码
function batchTransfer(address[] memory recipients, uint256 amount) external {
uint256 total = recipients.length * amount; // ← ⚠ 这里能溢出
require(balances[msg.sender] >= total, "not enough");
...
}
攻击者传 recipients.length = 2、amount = 2²⁵⁵——乘积 2²⁵⁶ 超出 uint256 上界,回绕成 0。require(balance >= 0) 必然通过,然后每个收件人白拿 2²⁵⁵ 个 BEC。整个 token 市值秒归零。
亲眼看一次溢出——下面这个 Playground 把 BEC 漏洞的核心一行(length * amount)抽出来,分两个版本对比:
操作三步走:
- compile + deploy
- 点 ◌ powerOf2_255 —— inline 显示
= 57896044618658097711785492504343953926634992332820282019728792003956564819968(这就是 2²⁵⁵)。复制这个数 - 把它粘进 safeMultiply 的 amount 输入 → ▶ 调用 → output:
× safeMultiply() reverted(panic 0x11 · arithmetic overflow) - 再把同一个数粘进 unsafeMultiply → ▶ 调用 → inline:
= 0🤯
理解 BEC 当年发生了什么:那条 unsafeMultiply 在 0.4.x 时代就是默认行为。total = 0 通过 require(balance >= 0)——然后给每个 recipient 转账 2²⁵⁵,凭空多出来一堆 token。市值秒归零。
0.8.0 起默认 revert 溢出——所以现代合约自带这层保护,但
unchecked { ... }仍可绕过;读 < 0.8 的老合约(2017-2019 上线的)必须手动看有没有 SafeMath。
陷阱 2 · bool 默认 false · 让黑名单全员通过
storage 里所有没显式赋值的字段都是类型的零值——uint = 0、bool = false、address = address(0)。包括 mapping 里所有”从来没存过”的 key。新手最常写出这种 bug:
function transfer(address to, uint amt) external {
require(!blacklist[msg.sender], "you are blocked");
// ↑ 默认 blacklist[任何人] = false · !false = true · 谁都过
}
亲眼看一次”黑名单空转”——下面 Playground 里这份 Blacklist 合约没人在里面,但 tryTransfer 仍然在做”!blacklist[user]” 检查:
操作 5 步(注意观察每一步的 inline 结果):
- compile + deploy(部署者 Alice 自动变成 owner)
- 在
isBlacklisted输入 Bob 的地址(点 ◉ Bob 旁边的地址复制,或用账户切换器里的地址)→ ▶ → inline:= false(Bob 从没被加进过 mapping) - 再调
tryTransfer同样输入 Bob 的地址 → ▶ → inline:= "transfer ok"—— Bob 通过了所谓的”黑名单”检查! - 调
addToBlacklist(Bob)→ 这次显式把 Bob 加进去 - 再
tryTransfer(Bob)→ output:× tryTransfer() reverted: you are blocked
AHA 时刻:第 3 步通过、第 5 步阻止——但第 3 步Bob 从未被允许过!这条 require(!blacklist[user]) 默认就是”放行任何没显式记过的地址”。审计报告里反复出现的低级错误。
修复方向:
- 白名单(默认拒绝):
require(allowlist[user], "not whitelisted")—— 默认 false,必须显式 true 才过 - 或确保黑名单被正确初始化(但这本质是用错了方向)
- 关键认知:mapping 里”没存过”和”显式设为零值”在链上是同一件事,根本分不清——所以涉及权限的逻辑要用”必须显式设过才放行”的写法
🔥 复杂类型 · 动手玩 · string 不能直接 ==
复杂类型有更多坑——下面这个 Playground 演示最经典的一个:Solidity 里 string == string 是编译错,必须先 hash 再比较。这条规则让一类钓鱼攻击有了可乘之机。
试这三组输入(复制粘贴到 compareSafe 的两个 input 框):
| a | b | compareSafe 返回 | 解读 |
|---|---|---|---|
admin | admin | true ✅ | 一样就是一样 |
admin | Admin | false | 大小写敏感 |
admin | аdmin ← 第一个字符是西里尔字母 а(U+0430),不是 ASCII a | false | 肉眼一模一样 · 链上判定不等 |
钓鱼攻击的可乘之机:攻击者注册 аdmin.eth(西里尔 а 开头)冒充 admin.eth,在前端展示完全相同——但合约里基于字符串相等的白名单根本认不出,会把”假 admin”放进来。Etherscan 上 2022-2023 多次出现钓鱼合约用这招冒充知名协议。这就是为什么严肃的协议从不依赖 string 比较做权限判断——直接用 address(简单类型,唯一的 32 字节)。
💡 复杂类型必须显式标注数据位置(storage / memory / calldata)——Solidity 强迫你写明白”这把钥匙指向哪个文件柜”,因为它直接决定改写代价和谁能看见。
B.3 · storage / memory / calldata · 数据住在哪
L2 D 提到 EVM 有六块内存区域——这里只展开最关键的三块:
| 关键字 | 住哪 | 生命周期 | gas 单价 | 谁能写 |
|---|---|---|---|---|
storage | 链上(合约自己的 MPT) | 永久 | 🔥 极贵(SSTORE 22100/2900 gas) | 函数内 |
memory | 节点 RAM(本次调用) | 调用结束就清空 | 中(MSTORE 3 gas) | 函数内 |
calldata | tx 调用数据(外部 input) | 调用结束就清空 | 便宜(只读) | ❌ 只读 |
读懂一份合约的本质就是看清”这个变量住在哪、这次操作改的是临时还是永久状态”。
💡 售货机现在还没有带复杂类型参数的函数,所以
memory/calldata关键字暂时还看不到(C 模块加了函数之后会自然出现)。这里先用一个抽象 widget 把三块区域的差别说清楚——
◆ Memory Areas Probe · 改一个关键字看看会发生什么
memory mode
◢ solidity
function process(string memory s) external returns (uint) {
bytes memory b = bytes(s); // s 是 EVM memory 的副本
return b.length;
}◢ where does `s` live?
节点 RAM · 本次调用
EVM memory · 临时 · 调用结束清空
◢ what can you do
- read✅ yes
- write✅ yes
- cost💨 dies after call
◢ gas costs · memory 行被高亮
| op | gas | note |
|---|---|---|
| SLOAD · storage read | 2,100 | cold slot 第一次读 |
| SSTORE · 0 → non-zero | 22,100 | 第一次写入 |
| SSTORE · non-zero → non-zero | 2,900 | 改一个已存在的 slot |
| MLOAD · memory read | 3 | 从 EVM memory 取 32 字节 |
| MSTORE · memory write | 3 | 写 32 字节进 memory |
| memory expansion | ~quadratic | memory 越大越贵 |
| CALLDATALOAD · read 32 bytes | 3 | 从 tx.data 偏移读 |
| calldata byte cost | 16 / 4 | 非零 / 零字节 (tx 入账) |
| write to calldata | ❌ not allowed | 编译期就拒绝 |
◢ takeaway
memory 是临时草稿纸 · 调用结束就清空 · 读写都便宜
💡 三选一的窍门:函数参数能用
calldata就用calldata(最便宜 · 只读);要在函数里临时改一改用memory;要写回链上才用storage。把这个反射建立起来,gas 就省下来了一大半。
B.4 · msg.* 三件套 · 这一笔调用的”信封信息”
下面读 C 模块的函数代码时,你会看到 msg.sender / msg.value 这些写法不知道从哪冒出来——没有声明、不是参数、却能直接用。先把这个谜底交代清楚:
L2 说过一笔 tx 是个信封,上面写着 from / value / data 三栏。合约怎么从被调用方读到这三栏?通过一个全局对象 msg——EVM 在函数被调用的瞬间自动塞进来,你不声明、不传参,伸手就能拿。
| 字段 | 类型 | 是什么 | 在售货机里哪用? |
|---|---|---|---|
msg.sender | address | 调这个函数的人(钱包地址 / 上游合约) | C 模块 constructor 记 owner · C.3 onlyOwner 检查 · D.1 bought 记账 · E.1 事件广播 |
msg.value | uint256 | 这笔调用带了多少 wei | C.2 buySoda 收钱 · F.1 receive 兜底 |
msg.data | bytes calldata | 整段 calldata 字节(4 字节 selector + 编码参数) | 很少直接读 · F.2 fallback 兜底时用得上 |
一句话总结:msg.* 就是合约从 tx 信封里抽出来读的指标——L2 站在 tx 那一侧看”信封是什么”,L3 这里站在合约这一侧看”怎么读信封”。同一笔 tx,两种视角。
// 看一眼下面这两行,msg.sender 和 msg.value 不需要声明就能用
constructor() {
owner = msg.sender; // 谁部署的合约,谁就是 owner
}
function buySoda() external payable {
require(msg.value >= sodaPrice, "not enough ETH"); // 你带的钱够不够
}
⚠️
msg.sender不一定是真人——当合约 A 调合约 B 时,B 看到的msg.sender是 A 这个合约的地址,不是最初点钱包按钮的那个人。这条引出了 delegatecall / 代理合约 / phishing 一整套 L4-L5 安全话题,先记住这一点:sender 是”直接上游”,不是”最初发起人”(最初发起人要看tx.origin,但tx.origin是另一个反模式,几乎不该用)。
模块 C · 函数
C.1 · 函数定义 · 参数 · 返回值
现在给售货机加上第一个函数——buySoda,先做最简单的版本:被调一次,库存减 1:
◆ VendingMachine.sol · v3 · + 最简的 buySoda
只做一件事:sodaCount 减 1(暂时不验证钱、不收钱)
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5 address public owner;6 uint256 public sodaPrice = 0.001 ether;7 uint256 public sodaCount = 100;89 constructor() {10 owner = msg.sender;11 }1213 function buySoda() external {new14 sodaCount -= 1;new15 }new16}
把这一行拆开看——一个完整 Solidity 函数签名固定有 5 个位置,本版本第 ④ 位是空的:
function buySoda() external ⟨ · · · · · · ⟩ { ... }
└─1─┘ └─ 2 ─┘ └─ 3 ─┘ └──── 4 ────┘ └5┘
state mutability
缺省 = nonpayable
- ①
function· 宣告这是一个函数 - ② 函数名 + 参数列表 · 函数名进 ABI、被
keccak256("buySoda()")的前 4 字节作 selector - ③ 可见性 ·
external/public/internal/private - ④ state mutability ·
view/pure/payable—— 本版本这一槽是空的(默认值nonpayable:能改 storage、但不能收 ETH)。下一节 C.2 给 buySoda 加payable,你就能看见这一槽被填上具体值 - ⑤ 函数体 · 真正干活的地方
💡 这 5 位之外,函数签名还可以插进两类东西:modifier(自定义的进门检查,C.3 会加
onlyOwner)和returns (...)(返回类型,需要时才出现)。它们都是可选项;这一节先把骨架认全。
C.2 · view / pure / payable · meter 怎么算
回忆 L2 D 那个出租车计价表——每条指令都跳一次格。这几个修饰符相当于在告诉计价表:“这趟会怎么花钱”。
buySoda 是要收钱的,所以加上 payable;同时加上两条 require 把”钱不够”和”卖完了”拦住:
◆ VendingMachine.sol · v4 · + payable + require
加 payable 让函数能收 ETH · 加两条 require 把"钱不够"和"卖完"拦下
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5 address public owner;6 uint256 public sodaPrice = 0.001 ether;7 uint256 public sodaCount = 100;89 constructor() {10 owner = msg.sender;11 }1213 function buySoda() external payable {new14 require(msg.value >= sodaPrice, "not enough ETH");new15 require(sodaCount > 0, "sold out");new16 sodaCount -= 1;17 }18}
payable(看 buySoda 的新签名)· 函数能收 ETH · 没有这个标记的函数收到 ETH 会直接 revertview· 看一眼不打表 · 只读 storage、不写(用例:getCount()之类,本节没加)pure· 完全不碰状态 · 连读都不读,输入输出就是纯函数(用例:add(uint a, uint b),本节没加)
| 修饰符 | 能改 storage? | 能读 storage? | 能收 ETH? |
|---|---|---|---|
| (默认) | ✅ | ✅ | ❌ |
view | ❌ | ✅ | ❌ |
pure | ❌ | ❌ | ❌ |
payable | ✅ | ✅ | ✅ |
C.3 · modifier · 把检查抽出来
类比 · modifier 就是函数门口的保安——进门前先掏证件、保安认可了才放你进去执行函数体。onlyOwner 是最常见的版本。我们给售货机加 onlyOwner modifier、再加一个 setPrice 函数让 owner 调价:
◆ VendingMachine.sol · v5 · + onlyOwner + setPrice
把"是不是 owner"抽成 modifier · 用它保护新加的 setPrice 函数
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5 address public owner;6 uint256 public sodaPrice = 0.001 ether;7 uint256 public sodaCount = 100;89 constructor() {10 owner = msg.sender;11 }1213 modifier onlyOwner() {new14 require(msg.sender == owner, "not owner");new15 _;new16 }new1718 function buySoda() external payable {19 require(msg.value >= sodaPrice, "not enough ETH");20 require(sodaCount > 0, "sold out");21 sodaCount -= 1;22 }2324 function setPrice(uint256 newPrice) external onlyOwner {new25 sodaPrice = newPrice;new26 }new27}
那个孤零零的 _;(modifier 体里)是占位符——编译器把原函数体整段塞到这一行的位置。所以 setPrice 实际被编译成”先检查 owner,再跑原本的函数体”。
💡 modifier 可以堆叠:
function f() public onlyOwner whenNotPaused { ... }会按从左到右的顺序嵌套包装——先过onlyOwner这一关,再过whenNotPaused,最后才到函数体。
模块 D · 复合数据结构
D.1 · mapping · 链上的 K/V 表
给售货机加一个 mapping:记录每个地址买过几瓶——buySoda 里同步给买家 +1:
◆ VendingMachine.sol · v6 · + bought mapping
加 mapping 记录每个地址买过几瓶 · buySoda 里给买家 +1
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5 address public owner;6 uint256 public sodaPrice = 0.001 ether;7 uint256 public sodaCount = 100;89 mapping(address => uint256) public bought;new1011 constructor() {12 owner = msg.sender;13 }1415 modifier onlyOwner() {16 require(msg.sender == owner, "not owner");17 _;18 }1920 function buySoda() external payable {21 require(msg.value >= sodaPrice, "not enough ETH");22 require(sodaCount > 0, "sold out");23 sodaCount -= 1;24 bought[msg.sender] += 1;new25 }2627 function setPrice(uint256 newPrice) external onlyOwner {28 sodaPrice = newPrice;29 }30}
类比 · mapping 就像邮政编码 → 住户的对照表:给一个 key(邮编),瞬间查到对应 value(住户)。底层是 keccak256(key . slot) 算出的 storage slot 位置——O(1) 命中。
注意两件反直觉的事:
- 不能遍历 · mapping 不维护 key 列表,写
for (k in bought)是非法的——想列出所有 key 必须自己再开一个数组同步维护 - 默认值不是 “未设置” · 没存过的 key 返回类型的零值(
uint → 0·bool → false·address → address(0))。没法区分”从来没设过”和”显式设成 0”——这点经常坑人
D.2 · struct · 一身份多字段
类比 · struct 像一本护照——多个字段(姓名 · 国籍 · 生日 · 签发日 · …)打包成一个身份。售货机暂时还没用到,但常见形态长这样:
struct Refund {
uint256 amount;
uint256 timestamp;
address user;
}
Refund storage r = refunds[id]; // 引用 · 改 r 就是改链上原数据
Refund memory m = refunds[id]; // 拷贝 · 改 m 不影响链上
💡
storagevsmemory这个区分在 struct 上经常成为 bug 来源——拿到memory副本改完一通忘了写回,链上数据纹丝不动。
D.3 · 数组 · 动态 vs 定长
| 类型 | 长度 | 怎么存 | gas 含义 |
|---|---|---|---|
uint[10] · 定长 | 编译期就定 · 永不变 | 紧贴 slot 排列 · 不存 length | 读写最便宜 |
uint[] · 动态 | 运行时可 push/pop | slot 存 length · 元素散在 keccak256(slot)+i | 改长度多一次 SSTORE |
uint[10] fixedArr; // 永远 10 个 · 越界编译期就拦
uint[] dynArr; // 长度跟着 push/pop 变 · 每次都要更新 length slot
⚠️ 动态数组的
push在 storage 上要写两格(新元素 + 更新 length),是个容易被忽视的 gas 黑洞。能用定长就用定长。
模块 E · 链上信号
E.1 · event · 链上的广播站
为什么以太坊要发明 Event?· 跟比特币比一比
读这一节之前先想一个问题:比特币上要怎么知道”某个地址刚收了一笔钱”? —— 你只能扫每一个新区块、解析里面每一笔 tx 的 output、看 scriptPubKey 是不是包含你这个地址。链上没有”通知”机制,只有”你自己来翻历史”。早期比特币钱包做余额查询就是这么干的——这套被叫做 polling,慢且费。
到了以太坊,问题更刁钻:智能合约里发生的事多得多 —— 谁调了哪个函数、转了多少 token、谁 mint 了一个 NFT、提案有没有通过……如果还指望”前端扫每个区块的每一笔 tx 的 calldata 然后反向解析”,dApp 就没法做了。
Vitalik 的解法是:让合约自己显式”广播”。合约里写 emit Tipped(msg.sender, 0.5 ether),EVM 就把这条信息记到专门的一块地方——receipt 树。这块地方有 3 个关键性质:
| 性质 | 怎么做到 | 为什么重要 |
|---|---|---|
| 极便宜 | log 不写 storage 树(22,100 gas/slot),写 receipt(~375 + 8 gas/byte,便宜 100 倍) | 让合约敢于频繁广播 · 不广播是因为太贵就跟没有这个机制一样 |
| 可被精确过滤 | log 带 topics 字段(最多 4 个 32-byte 哈希),区块头里有 logsBloom (2048 bit) | 前端能直接问”过去 100 个区块里凡是 topic[0] = keccak(‘Transfer’) 的 log”——节点 O(1) 答 |
| 结构化 | event 在合约 ABI 里有签名,前端拿到 log 后用 ABI 解码就能拿到原始字段 | 不需要解析 raw bytes · 钱包、Etherscan、The Graph 全靠这个 |
比特币 vs 以太坊 · 一句话对比:
比特币的”事件”只有”output 给某个地址”这一种,你只能 polling 整条链来找;以太坊的合约可以自定义任意事件、用 indexed 字段建索引,前端订阅就能立刻收到。从”自己翻账本”升级到了”合约推送通知”。
所有 ERC-20 钱包刷新余额、Etherscan 上你看到的”交易历史”、The Graph 上的链上数据索引——本质上都是在监听这套 event 机制。没有 event,dApp 这个概念几乎不成立。
给售货机加上 event
给售货机加一个 SodaSold event,每次卖瓶之后 emit 一下,让前端能监听到”谁买了 / 多少钱”:
◆ VendingMachine.sol · v7 · + event SodaSold
加事件声明 · 每次卖瓶后 emit 一下让前端能监听
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5 address public owner;6 uint256 public sodaPrice = 0.001 ether;7 uint256 public sodaCount = 100;89 mapping(address => uint256) public bought;1011 event SodaSold(address indexed buyer, uint256 price);new1213 constructor() {14 owner = msg.sender;15 }1617 modifier onlyOwner() {18 require(msg.sender == owner, "not owner");19 _;20 }2122 function buySoda() external payable {23 require(msg.value >= sodaPrice, "not enough ETH");24 require(sodaCount > 0, "sold out");25 sodaCount -= 1;26 bought[msg.sender] += 1;27 emit SodaSold(msg.sender, sodaPrice);new28 }2930 function setPrice(uint256 newPrice) external onlyOwner {31 sodaPrice = newPrice;32 }33}
event· 声明事件签名(要放在 contract 顶层)emit· 触发一次广播indexed· 让这个字段进 topics 而不是 data —— 进 topics 才能被前端精确过滤;每个 event 最多 3 个 indexed- log 不进 storage(便宜得多),但写进区块的 receipt 树(不可篡改 + 可证明)
一个 event 从 emit 到前端看见 · 四步走
下面这个 widget 把上面那行 emit 之后链上和链下发生的 4 件事逐步展开——每点一次 next → 揭一层。
◆ EventStream · 从 emit 到 frontend 看见
step 1 / 4
① emit · emit
Tip.sol
◢ 这一步发生了什么
合约代码里 emit 一行——这只是 Solidity 的语法糖。编译器把它翻成 EVM 的 LOG 操作码。注意:emit 本身不"做事",它只是声明"这次执行向外广播了这件事"。学生第一次看 Solidity 代码时容易以为 event 像数据库的 trigger——其实它更像 console.log,只不过这个 log 被永久写进了区块。
E.2 · require / revert / custom error · 三代错误处理
把售货机的 require 升级成现代的 custom error + revert:
◆ VendingMachine.sol · v8 · 现代化错误处理
用 custom error 替换 require 字符串 · gas 省 ~75% · 错误信息可带结构化参数
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5 address public owner;6 uint256 public sodaPrice = 0.001 ether;7 uint256 public sodaCount = 100;89 mapping(address => uint256) public bought;1011 event SodaSold(address indexed buyer, uint256 price);12 error SoldOut();new13 error NotEnoughETH(uint256 sent, uint256 required);new1415 constructor() {16 owner = msg.sender;17 }1819 modifier onlyOwner() {20 require(msg.sender == owner, "not owner");21 _;22 }2324 function buySoda() external payable {25 if (sodaCount == 0) revert SoldOut();new26 if (msg.value < sodaPrice) revert NotEnoughETH(msg.value, sodaPrice);new27 sodaCount -= 1;28 bought[msg.sender] += 1;29 emit SodaSold(msg.sender, sodaPrice);30 }3132 function setPrice(uint256 newPrice) external onlyOwner {33 sodaPrice = newPrice;34 }35}
旁边对比一下三种错误写法的演进:
// 1 · 最老 · require 带字符串
require(msg.value >= sodaPrice, "not enough ETH");
// 2 · 0.4+ · revert 带字符串
if (msg.value < sodaPrice) revert("not enough ETH");
// 3 · 0.8.4+ · custom error(售货机用的这种)
error NotEnoughETH(uint256 sent, uint256 required);
if (msg.value < sodaPrice) revert NotEnoughETH(msg.value, sodaPrice);
三种错误写法的对比:
| 风格 | 出现 | gas 成本 | 错误信息携带 |
|---|---|---|---|
require(cond, "msg") | 早期 | 高(字符串嵌进 bytecode) | 字符串 |
revert("msg") | 0.4+ | 高 | 字符串 |
revert CustomError(args) | 0.8.4+ | 低(~75% 节省) | 4 字节 selector + ABI 编码参数 |
现代项目几乎都用 custom error——既省 gas(部署 + 触发都便宜),又能携带结构化参数(前端能解码出”差多少 ETH”、“哪个用户”),还自动展示在 Etherscan 的 revert reason 上。
💡 L2 D 提到的 revert 在这里落地——执行中遇到 revert,所有 storage 改动被回滚,但 gas 已经付了。
模块 F · 入口和出口
F.1 · constructor · 部署时跑一次
回到售货机最早的版本——constructor 一直在那(B.1 我们就加了),但当时我们没展开。现在补足细节:
◆ VendingMachine.sol · v2 · + 状态变量 + constructor
加 3 个 public 字段 · constructor 在部署时把 owner 写好
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5 address public owner;new6 uint256 public sodaPrice = 0.001 ether;new7 uint256 public sodaCount = 100;new89 constructor() {new10 owner = msg.sender;new11 }new12}
constructor 是合约的一次性初始化函数——部署那一刻跑一次,之后再也不会被调用(它的字节码也不会进入合约的 runtime code,只在部署 tx 里出现)。常见用途:
- 设 owner ·
owner = msg.sender——记下”谁部署了我” - 初始化常量参数 · 总供应量、利率、token 名字这些一次写好不再动
- 注册依赖地址 · 把要调用的别的合约地址传进来存住
💡 既然 constructor 只跑一次,写错也没机会改——
immutable字段就是为这个场景生的:构造里赋值,之后永远只读,比 storage 便宜得多。
F.2 · receive / fallback · 兜底入口
合约除了正常的函数入口,还有两个兜底入口——类似房子的后门和应急通道。最后给售货机加上 receive() 收钱、fallback() 兜住未知调用:
◆ VendingMachine.sol · v9 · + receive + fallback
兜底两个入口 · 纯转账走 receive · 找不到函数走 fallback
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34contract VendingMachine {5 address public owner;6 uint256 public sodaPrice = 0.001 ether;7 uint256 public sodaCount = 100;89 mapping(address => uint256) public bought;1011 event SodaSold(address indexed buyer, uint256 price);12 error SoldOut();13 error NotEnoughETH(uint256 sent, uint256 required);1415 constructor() {16 owner = msg.sender;17 }1819 modifier onlyOwner() {20 require(msg.sender == owner, "not owner");21 _;22 }2324 function buySoda() external payable {25 if (sodaCount == 0) revert SoldOut();26 if (msg.value < sodaPrice) revert NotEnoughETH(msg.value, sodaPrice);27 sodaCount -= 1;28 bought[msg.sender] += 1;29 emit SodaSold(msg.sender, sodaPrice);30 }3132 function setPrice(uint256 newPrice) external onlyOwner {33 sodaPrice = newPrice;34 }3536 receive() external payable {}37 new38 fallback() external {39 revert("unknown function");new40 }new41}new
| 函数 | 触发条件 | 必须 payable? |
|---|---|---|
receive() | 收到 ETH 且 calldata 为空(纯转账) | ✅ 必须 |
fallback() | calldata 找不到匹配函数 selector · 或没有 receive 时的纯转账 | 可选(加了能收 ETH) |
判断顺序比上面表格里两行字复杂——下面这个决策树把所有可能的路径画出来,点 5 种典型 tx 情形看它最终命中哪个入口:
◆ receive vs fallback · tx 进来该走哪个入口
点 5 种情形看路径
◢ 这一笔 tx
tx { value: 1 ETH, data: 0x }◢ 合约里有什么
◢ 最终命中
✓ receive()
走 receive() · 这是最常见的 ETH 转账接收方式
👋 动手玩 · “没有 receive 的合约不能收钱”
下面这个 Vault 合约当前没有 receive 也没有 fallback。按这两步做实验,亲眼看一遍效果:
第一轮 · 现在(合约里只有 take(),没有 receive):
- ▶ compile → ⬢ deploy
- 在 send eth 那一行:点 ◆ contract 把合约地址自动填进 to,金额输
1,点 → send - 观察:output 出现
transfer reverted——EVM 找不到入口,拒收
第二轮 · 加上 receive:
- 把代码里注释掉的
receive()函数取消注释(去掉那 4 行前面的//) - ▶ compile → ⬢ deploy(重新部署,地址会变)
- 再点 ◆ contract 把新地址填进 to,发 1 ETH
- 观察:output 出现
transfer ok,再点received()view 函数,inline 直接显示= 1000000000000000000(即 1 ETH 的 wei 值)
为什么会这样:你按 send 时,Playground 发了一笔 value = 1 ether, data = 0x 的 tx。EVM 路由到合约时按下面这张表查”该调哪个函数”:
| tx 长什么样 | 合约里有什么 | 走哪个入口 |
|---|---|---|
value > 0 · data = 0x | 有 receive() | → receive() |
value > 0 · data = 0x | 没 receive() 但有 payable fallback() | → fallback() |
value > 0 · data = 0x | 都没有(第一轮的情形) | revert ✗ |
value > 0 · data = 函数 selector | 该 selector 对应的 payable 函数 | → 那个函数 |
value > 0 · data = 未知 selector | 有 payable fallback() | → fallback() |
value > 0 · data = 任何形式 | 都没对得上、也没 payable fallback | revert ✗ |
记住这条 takeaway:合约默认不能收钱。要让一个合约接受裸转账,必须显式声明 receive() payable 或 fallback() payable——Solidity 这个设计是”安全默认值”,逼你 explicit 地同意收钱。
⚠️ CEI 安全模式预告:
receive/fallback里如果再去调外部合约或转账,要遵循 Checks-Effects-Interactions——先检查、再改自己 state、最后才动外部。否则就是 2016 The DAO 那种 reentrancy 漏洞的温床。L5 专题展开。
F.3 · 互动 · 读 40 行 TipJar.sol
把这一课学的 8 件事打包用一次——下面这段 40 行的 TipJar 合约里,每一行你都应该知道它在干什么。读完合约后回答 5 个问题,全答对就算通过。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TipJar {
address public owner;
mapping(address => uint256) public tippedBy;
event Tipped(address indexed from, uint256 amount);
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function tip() external payable {
require(msg.value > 0, "send something");
tippedBy[msg.sender] += msg.value;
emit Tipped(msg.sender, msg.value);
}
function withdraw() external onlyOwner {
(bool ok, ) = owner.call{value: address(this).balance}("");
require(ok, "withdraw failed");
}
receive() external payable {
tippedBy[msg.sender] += msg.value;
emit Tipped(msg.sender, msg.value);
}
}
🎬 收尾 · 8 件事打勾 + 通向 L4
读到这里你应该能:
- 打开 Etherscan 上任何 verified 合约的源码
- 逐行说出”这是什么类型、住在哪、谁能调”
- 看到一个
function ... onlyOwner external payable returns (bool)不再觉得长
但读懂 ≠ 能写。下一课开始:把合约连起来——继承、interface、ERC-20、合约调合约。今天读的 TipJar 在 L4 会被你扩成一个真正的 staking 协议。
思考题:上面的 TipJar 有个安全漏洞——
withdraw那行(bool ok, ) = owner.call{value: ...}("")看起来人畜无害,但放到一个更复杂的合约里就是经典的攻击面。等 L5 我们专门讲这个。