◢ Lesson 3 ⏱ 105 min

学会读 Solidity

🎬 开场 · 先读,再写

上一课结尾我们说”下一课开始它”——但更诚实的版本是:写之前得先读得懂

ETH 上每个 verified 合约的源码都是公开的。你想在 DeFi 里赚钱、想审一段陌生合约的安全性、想看看 Uniswap 到底干了什么——任何严肃的链上动作前都得先把别人的代码读懂。这一课就教会你怎么读。


👋 先碰一下 · 智能合约到底是个什么东西

先别管语法。下面这就是一份完整的、能跑的售货机智能合约。先动手玩一遍,感受一下:写一段代码 → 部署上链 → 调用它——这整个流程,就是”智能合约”。

试试这三步

  1. ▶ compile ——把 Solidity 源码编译成 EVM 字节码
  2. ⬢ deploy ——把字节码作为一笔 transaction 上链,生成一个合约地址
  3. 在 functions 区找到 buySoda,value 输 0.001 ether,点 ▶ ——花 0.001 ETH 买一瓶汽水,sodaCount 从 100 减到 99
    • 顺便再点一次 ▶ buySoda 但 value 输 0——它会 revert “send enough”,因为合约里有 require 检查

刚才发生的事,用一张图说清楚——点右上角的 next → 一步步看

◆ vending machine · lifecycle

step 1 / 3 · compile
compile
solidity → bytecode
contract VendingMachine {
sodaPrice = 0.001 ether;
sodaCount = 100;
function buySoda() {...}
}
solc
bytecode · 4.2 kb
0x6080604052348015600f57600080fd5b50…
deploy
bytecode → tx → block → address
miner picks it up
call
invoke function → tx → state changes
EVM runs buySoda()

chain state · what's actually on the blockchain

合约还没部署 · 链上没有这份代码
// ① 写好的代码先编译成字节码 → ② 部署 tx 上链拿到合约地址 + 初始 state → ③ 调用 tx 上链触发函数 → state 永久改变

这就是智能合约的全部生命周期:源码 → 字节码 → 链上地址 → 永久 state,调用一次状态改一次。下面 8 节会把这份合约的每一行拆开讲——每讲完一个概念,售货机就在你眼前长出对应的那段代码。


今天的检查表

L1 用「银行职能 8 项」组装出 BTC、L2 用「世界计算机 6 个零件」组装出 ETH。这一课换一张清单——「读懂一份合约的 8 件事」

item 1 · 文件头📜pragma / SPDX
item 2 · 状态🏷️状态变量 · 可见性
item 3 · 类型🧬简单 vs 复杂类型
item 4 · 内存🧠storage / memory / calldata
item 5 · 函数⚙️函数 · modifier · view/pure
item 6 · 复合数据🗂️mapping · struct · array
item 7 · 事件 / 错误📡event · error · revert
item 8 · 入口🚪constructor · receive · fallback

每讲完一项,右上角浮窗自动勾掉对应一项。八项全亮,你就能读懂任何一份合约

💡 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: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5}

这就是本课的锚点——一份会陪你一路成长的合约。每一节都会回到它、给它加点东西。新加的几行会用 ↑ accent 色 + new 标记标出来。

A.2 · SPDX + pragma · 信封外的东西

每份 Solidity 文件的前两行几乎都是相同的格式——回到售货机的 1-2 行:

◆ VendingMachine.sol · v1 · 文件外壳

只有 pragma + 空 contract

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5}

一个具体例子:同一段代码在两个版本下行为完全不同。

// 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: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5 address public owner;new
6 uint256 public sodaPrice = 0.001 ether;new
7 uint256 public sodaCount = 100;new
8
9 constructor() {new
10 owner = msg.sender;new
11 }new
12}

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 复杂类型 · 大小的分水岭

一句话标尺

简单类型复杂类型
uint256 / int256string
boolbytes
address / address payableT[] 数组
bytes32 / bytes1-bytes32struct
enummapping

一个直觉:简单类型像数字写在小纸条上,递给别人时整张纸条复印一份,对方涂改不影响你手上那张;复杂类型像一个文件柜,递给别人时只是给了对方一把钥匙,对方动了文件你这边也跟着变。

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 = 2amount = 2²⁵⁵——乘积 2²⁵⁶ 超出 uint256 上界,回绕成 0require(balance >= 0) 必然通过,然后每个收件人白拿 2²⁵⁵ 个 BEC。整个 token 市值秒归零。

亲眼看一次溢出——下面这个 Playground 把 BEC 漏洞的核心一行(length * amount)抽出来,分两个版本对比:

操作三步走

  1. compile + deploy
  2. ◌ powerOf2_255 —— inline 显示 = 5789604461865809771178549250434395392663499233282028201972879200395656481​9968(这就是 2²⁵⁵)。复制这个数
  3. 把它粘进 safeMultiply 的 amount 输入 → ▶ 调用 → output: × safeMultiply() reverted(panic 0x11 · arithmetic overflow)
  4. 再把同一个数粘进 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 = 0bool = falseaddress = 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 结果):

  1. compile + deploy(部署者 Alice 自动变成 owner)
  2. isBlacklisted 输入 Bob 的地址(点 ◉ Bob 旁边的地址复制,或用账户切换器里的地址)→ ▶ → inline: = false(Bob 从没被加进过 mapping)
  3. 再调 tryTransfer 同样输入 Bob 的地址 → ▶ → inline: = "transfer ok" —— Bob 通过了所谓的”黑名单”检查!
  4. addToBlacklist(Bob) → 这次显式把 Bob 加进去
  5. tryTransfer(Bob) → output: × tryTransfer() reverted: you are blocked

AHA 时刻:第 3 步通过、第 5 步阻止——但第 3 步Bob 从未被允许过!这条 require(!blacklist[user]) 默认就是”放行任何没显式记过的地址”。审计报告里反复出现的低级错误。

修复方向

🔥 复杂类型 · 动手玩 · string 不能直接 ==

复杂类型有更多坑——下面这个 Playground 演示最经典的一个:Solidity 里 string == string 是编译错,必须先 hash 再比较。这条规则让一类钓鱼攻击有了可乘之机。

试这三组输入(复制粘贴到 compareSafe 的两个 input 框):

abcompareSafe 返回解读
adminadmintrue一样就是一样
adminAdminfalse大小写敏感
adminаdmin ← 第一个字符是西里尔字母 а(U+0430),不是 ASCII afalse肉眼一模一样 · 链上判定不等

钓鱼攻击的可乘之机:攻击者注册 а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)函数内
calldatatx 调用数据(外部 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 · 本次调用

EXECUTION NODE · CALL FRAMECALLDATA · 原件tx.data"alice.eth"ABI DECODE复制一份EVM memory · 副本从 0x00 开始的字节数组 · 函数体真正读写的就是这里0x00free_ptr0x40...0x80s.len0xa0s[0..]s 的副本住在 0x80 起的几个字节💨 call ends → RAM 清零(原件 calldata 也消失)函数运行时 · `s` 同时存在两个地方

EVM memory · 临时 · 调用结束清空

◢ what can you do

  • read✅ yes
  • write✅ yes
  • cost💨 dies after call

◢ gas costs · memory 行被高亮

opgasnote
SLOAD · storage read2,100cold slot 第一次读
SSTORE · 0 → non-zero22,100第一次写入
SSTORE · non-zero → non-zero2,900改一个已存在的 slot
MLOAD · memory read3从 EVM memory 取 32 字节
MSTORE · memory write3写 32 字节进 memory
memory expansion~quadraticmemory 越大越贵
CALLDATALOAD · read 32 bytes3从 tx.data 偏移读
calldata byte cost16 / 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.senderaddress调这个函数的人(钱包地址 / 上游合约)C 模块 constructor 记 owner · C.3 onlyOwner 检查 · D.1 bought 记账 · E.1 事件广播
msg.valueuint256这笔调用带了多少 weiC.2 buySoda 收钱 · F.1 receive 兜底
msg.databytes 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.senderA 这个合约的地址,不是最初点钱包按钮的那个人。这条引出了 delegatecall / 代理合约 / phishing 一整套 L4-L5 安全话题,先记住这一点:sender 是”直接上游”,不是”最初发起人”(最初发起人要看 tx.origin,但 tx.origin 是另一个反模式,几乎不该用)。


模块 C · 函数

C.1 · 函数定义 · 参数 · 返回值

现在给售货机加上第一个函数——buySoda,先做最简单的版本:被调一次,库存减 1:

◆ VendingMachine.sol · v3 · + 最简的 buySoda

只做一件事:sodaCount 减 1(暂时不验证钱、不收钱)

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5 address public owner;
6 uint256 public sodaPrice = 0.001 ether;
7 uint256 public sodaCount = 100;
8
9 constructor() {
10 owner = msg.sender;
11 }
12
13 function buySoda() external {new
14 sodaCount -= 1;new
15 }new
16}

把这一行拆开看——一个完整 Solidity 函数签名固定有 5 个位置,本版本第 ④ 位是空的:

function  buySoda()  external  ⟨ · · · · · · ⟩  { ... }
  └─1─┘    └─ 2 ─┘    └─ 3 ─┘   └──── 4 ────┘    └5┘
                                state mutability
                                缺省 = nonpayable

💡 这 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: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5 address public owner;
6 uint256 public sodaPrice = 0.001 ether;
7 uint256 public sodaCount = 100;
8
9 constructor() {
10 owner = msg.sender;
11 }
12
13 function buySoda() external payable {new
14 require(msg.value >= sodaPrice, "not enough ETH");new
15 require(sodaCount > 0, "sold out");new
16 sodaCount -= 1;
17 }
18}
修饰符能改 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: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5 address public owner;
6 uint256 public sodaPrice = 0.001 ether;
7 uint256 public sodaCount = 100;
8
9 constructor() {
10 owner = msg.sender;
11 }
12
13 modifier onlyOwner() {new
14 require(msg.sender == owner, "not owner");new
15 _;new
16 }new
17
18 function buySoda() external payable {
19 require(msg.value >= sodaPrice, "not enough ETH");
20 require(sodaCount > 0, "sold out");
21 sodaCount -= 1;
22 }
23
24 function setPrice(uint256 newPrice) external onlyOwner {new
25 sodaPrice = newPrice;new
26 }new
27}

那个孤零零的 _;(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: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5 address public owner;
6 uint256 public sodaPrice = 0.001 ether;
7 uint256 public sodaCount = 100;
8
9 mapping(address => uint256) public bought;new
10
11 constructor() {
12 owner = msg.sender;
13 }
14
15 modifier onlyOwner() {
16 require(msg.sender == owner, "not owner");
17 _;
18 }
19
20 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;new
25 }
26
27 function setPrice(uint256 newPrice) external onlyOwner {
28 sodaPrice = newPrice;
29 }
30}

类比 · mapping 就像邮政编码 → 住户的对照表:给一个 key(邮编),瞬间查到对应 value(住户)。底层是 keccak256(key . slot) 算出的 storage slot 位置——O(1) 命中。

注意两件反直觉的事:

D.2 · struct · 一身份多字段

类比 · struct 像一本护照——多个字段(姓名 · 国籍 · 生日 · 签发日 · …)打包成一个身份。售货机暂时还没用到,但常见形态长这样:

struct Refund {
    uint256 amount;
    uint256 timestamp;
    address user;
}

Refund storage r = refunds[id];  // 引用 · 改 r 就是改链上原数据
Refund memory  m = refunds[id];  // 拷贝 · 改 m 不影响链上

💡 storage vs memory 这个区分在 struct 上经常成为 bug 来源——拿到 memory 副本改完一通忘了写回,链上数据纹丝不动。

D.3 · 数组 · 动态 vs 定长

类型长度怎么存gas 含义
uint[10] · 定长编译期就定 · 永不变紧贴 slot 排列 · 不存 length读写最便宜
uint[] · 动态运行时可 push/popslot 存 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: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5 address public owner;
6 uint256 public sodaPrice = 0.001 ether;
7 uint256 public sodaCount = 100;
8
9 mapping(address => uint256) public bought;
10
11 event SodaSold(address indexed buyer, uint256 price);new
12
13 constructor() {
14 owner = msg.sender;
15 }
16
17 modifier onlyOwner() {
18 require(msg.sender == owner, "not owner");
19 _;
20 }
21
22 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);new
28 }
29
30 function setPrice(uint256 newPrice) external onlyOwner {
31 sodaPrice = newPrice;
32 }
33}

一个 event 从 emit 到前端看见 · 四步走

下面这个 widget 把上面那行 emit 之后链上和链下发生的 4 件事逐步展开——每点一次 next → 揭一层。

◆ EventStream · 从 emit 到 frontend 看见

step 1 / 4

① emit · emit

Tip.sol

event Tipped(address indexed from, uint256 amount);
function tip() external payable {
emit Tipped(msg.sender, msg.value);
}

◢ 这一步发生了什么

合约代码里 emit 一行——这只是 Solidity 的语法糖。编译器把它翻成 EVM 的 LOG 操作码。注意:emit 本身不"做事",它只是声明"这次执行向外广播了这件事"。学生第一次看 Solidity 代码时容易以为 event 像数据库的 trigger——其实它更像 console.log,只不过这个 log 被永久写进了区块。

② LOG opcode· LOG opcode · 点 next → 来到这一步
③ receipt· into receipt · 点 next → 来到这一步
④ committed· committed · 点 next → 来到这一步

E.2 · require / revert / custom error · 三代错误处理

把售货机的 require 升级成现代的 custom error + revert:

◆ VendingMachine.sol · v8 · 现代化错误处理

用 custom error 替换 require 字符串 · gas 省 ~75% · 错误信息可带结构化参数

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5 address public owner;
6 uint256 public sodaPrice = 0.001 ether;
7 uint256 public sodaCount = 100;
8
9 mapping(address => uint256) public bought;
10
11 event SodaSold(address indexed buyer, uint256 price);
12 error SoldOut();new
13 error NotEnoughETH(uint256 sent, uint256 required);new
14
15 constructor() {
16 owner = msg.sender;
17 }
18
19 modifier onlyOwner() {
20 require(msg.sender == owner, "not owner");
21 _;
22 }
23
24 function buySoda() external payable {
25 if (sodaCount == 0) revert SoldOut();new
26 if (msg.value < sodaPrice) revert NotEnoughETH(msg.value, sodaPrice);new
27 sodaCount -= 1;
28 bought[msg.sender] += 1;
29 emit SodaSold(msg.sender, sodaPrice);
30 }
31
32 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: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5 address public owner;new
6 uint256 public sodaPrice = 0.001 ether;new
7 uint256 public sodaCount = 100;new
8
9 constructor() {new
10 owner = msg.sender;new
11 }new
12}

constructor 是合约的一次性初始化函数——部署那一刻跑一次,之后再也不会被调用(它的字节码也不会进入合约的 runtime code,只在部署 tx 里出现)。常见用途:

💡 既然 constructor 只跑一次,写错也没机会改——immutable 字段就是为这个场景生的:构造里赋值,之后永远只读,比 storage 便宜得多。

F.2 · receive / fallback · 兜底入口

合约除了正常的函数入口,还有两个兜底入口——类似房子的后门应急通道。最后给售货机加上 receive() 收钱、fallback() 兜住未知调用:

◆ VendingMachine.sol · v9 · + receive + fallback

兜底两个入口 · 纯转账走 receive · 找不到函数走 fallback

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4contract VendingMachine {
5 address public owner;
6 uint256 public sodaPrice = 0.001 ether;
7 uint256 public sodaCount = 100;
8
9 mapping(address => uint256) public bought;
10
11 event SodaSold(address indexed buyer, uint256 price);
12 error SoldOut();
13 error NotEnoughETH(uint256 sent, uint256 required);
14
15 constructor() {
16 owner = msg.sender;
17 }
18
19 modifier onlyOwner() {
20 require(msg.sender == owner, "not owner");
21 _;
22 }
23
24 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 }
31
32 function setPrice(uint256 newPrice) external onlyOwner {
33 sodaPrice = newPrice;
34 }
35
36 receive() external payable {}
37 new
38 fallback() external {
39 revert("unknown function");new
40 }new
41}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(): · fallback():
data = 0xdata ≠ 0x有 receive没 receivepayable不 payableselector 命中没命中有 fallback没 fallbacktx 到达data 是否为空?有 receive?fallback 是否 payable?selector 匹配?有 fallback?receive()fallback()revertmatched fnfallback()revert

◢ 最终命中

receive()

走 receive() · 这是最常见的 ETH 转账接收方式

// 判定顺序的本质:data 是否为空分流;空走 receive 优先链;非空走 selector 优先链。两条链的兜底都是 fallback, 所有兜底失败都是 revert。

👋 动手玩 · “没有 receive 的合约不能收钱”

下面这个 Vault 合约当前没有 receive 也没有 fallback。按这两步做实验,亲眼看一遍效果:

第一轮 · 现在(合约里只有 take(),没有 receive):

  1. ▶ compile → ⬢ deploy
  2. 在 send eth 那一行:点 ◆ contract 把合约地址自动填进 to,金额输 1,点 → send
  3. 观察:output 出现 transfer reverted——EVM 找不到入口,拒收

第二轮 · 加上 receive

  1. 把代码里注释掉的 receive() 函数取消注释(去掉那 4 行前面的 //
  2. ▶ compile → ⬢ deploy(重新部署,地址会变)
  3. 再点 ◆ contract 把地址填进 to,发 1 ETH
  4. 观察:output 出现 transfer ok,再点 received() view 函数,inline 直接显示 = 1000000000000000000(即 1 ETH 的 wei 值)

为什么会这样:你按 send 时,Playground 发了一笔 value = 1 ether, data = 0x 的 tx。EVM 路由到合约时按下面这张表查”该调哪个函数”:

tx 长什么样合约里有什么走哪个入口
value > 0 · data = 0xreceive()receive()
value > 0 · data = 0xreceive() 但有 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 fallbackrevert

记住这条 takeaway合约默认不能收钱。要让一个合约接受裸转账,必须显式声明 receive() payablefallback() 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

读到这里你应该能:

读懂 ≠ 能写。下一课开始:把合约连起来——继承、interface、ERC-20、合约调合约。今天读的 TipJar 在 L4 会被你扩成一个真正的 staking 协议。

思考题:上面的 TipJar 有个安全漏洞——withdraw 那行 (bool ok, ) = owner.call{value: ...}("") 看起来人畜无害,但放到一个更复杂的合约里就是经典的攻击面。等 L5 我们专门讲这个。