🎬 开场 · L3 教读 · L4 教写 · 把合约连起来组成 DApp
L3 我们把”读懂一份合约”拆成 8 件事——你现在能打开 Etherscan 上任何 verified 合约,逐行说出”这是什么类型、住在哪、谁能调”。
但一份合约还不是 DApp。
L2 的 C.6 我们画过一张”乐高城”——Uniswap、Compound、Aave 这些 DeFi 协议互相调用、互相搭积木。今天我们就钻进其中一块乐高,看看它内部是怎么搭出来的。
今天的检查表
L1 用「银行职能 8 项」组装出 BTC、L2 用「世界计算机 6 个零件」组装出 ETH、L3 用「读懂合约 8 件事」教你看懂别人的代码。这一课换一张清单——「一个 DApp 的 6 块乐高」:
每讲完一块乐高,右上角浮窗自动勾掉对应一项。六块全亮,你手上就有了一个 DApp 的所有零件。
💡 本课所有代码示例可以直接编辑、编译、运行——Playground 在浏览器里跑一个真 EVM。L3 你用它读 TipJar,L4 你用它造 Vault。
模块 A · 单合约 → 多合约系统
A.1 · 从单合约到系统
L2 那张乐高城——一笔 DeFi 闪电贷可能横跨十几份合约(Aave 借、Uniswap 换、Compound 平、原路还)。看起来眼花缭乱,但每个方框自己只有 200–500 行——L3 教的是读懂一个方框,L4 教的是怎么造出这样的方框、怎么和别的方框对话。
要造方框,你不需要更多语法——你需要组合代码的几种方式:复用、对话、升级。下面 6 块乐高把这套机制拆开。
A.2 · inheritance · 继承
不拿 Animal/Dog 举例——直接看一个ETH 链上每个合约几乎都在用的真实例子:Ownable(OpenZeppelin 提供的访问控制基类)。这是你打开 Etherscan 上几乎任意 verified 合约都能看到的继承链。
◢ solidity · 点亮的关键字可点 / hover 看含义
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /// Ownable 的全部就是:有个 owner + 一个 modifier /// OpenZeppelin 真实代码就比这多几个事件 · 整个 DeFi 都在继承它 abstract contract Ownable { address public owner; constructor() { owner = msg.sender; // 部署者自动成为 owner } modifier () { require(msg.sender == owner, "not owner"); _; } function transferOwnership(address newOwner) public onlyOwner { owner = newOwner; } } /// Vault 继承 Ownable · 立刻拿到 onlyOwner 全套 contract Vault Ownable { uint256 public stash; function deposit() external payable { stash += msg.value; } function withdraw() external { payable(owner).transfer(stash); stash = 0; } function transferOwnership(address newOwner) public onlyOwner { require(newOwner != owner, "no self-transfer"); .transferOwnership(newOwner); } }
类比 · 继承是组织架构图。Vault is Ownable = “Vault 部门下属于 Ownable 部门,白嫖父部门所有职能(owner / onlyOwner / transferOwnership),还可以加强某些规则(不能自转)“。Vault 这份合约自己只写了 stash + 3 行业务函数,权限管理全是继承的——这就是继承的价值:写一次,所有合约用。
💡 下一节 B.2 你会看到
MyToken is ERC20, Ownable这种多重继承——同一个合约可以继承多个父类,把 ERC-20 标准和 Ownable 的访问控制一起拿来用。Solidity 用 C3 linearization 决定多个父类的调用顺序,新手记住一条规则就够:先继承基础(Context、ERC20),再继承扩展(Ownable、Pausable、ReentrancyGuard)——按”父→子”的依赖方向写。
实战里继承最常见的模式是”继承一个标准实现 + 加一两个自己的修改”。比如 MyToken is ERC20, Ownable 这种声明就把 OpenZeppelin 写好的 ERC-20 标准和”只有 owner 能调某些函数”的能力都拿过来了。
🔺 菱形继承 · 上面说得抽象,下面这个例子真能跑
Solidity 支持多重继承——但同时继承多个父类时如果出现”同一函数被多个父合约 override”的情况,Solidity 用 C3 linearization 算出调用顺序。下面这 4 份合约组成一个最经典的菱形:B 和 C 都继承 A,D 同时继承 B 和 C;每层 override f() 并用 super.f() 调上一层,再拼一段自己的标识。
◢ solidity · 点亮的关键字可点 / hover 看含义
contract A { function f() public virtual returns (string memory) { return "A"; } } contract B is A { function f() public virtual override returns (string memory) { return (.f(), "->B"); } } contract C is A { function f() public virtual override returns (string memory) { return (.f(), "->C"); } } contract D { function f() public returns (string memory) { return (.f(), "->D"); } }
调 D.f() 会返回什么?
| 不是 | 而是 |
|---|---|
"A->B->D" | |
"A->C->D" | |
"A->D" | "A->B->C->D" ✓ |
为什么?——D 的 MRO = D → C → B → A(C3 linearization 算出来的,写在 is 列表越靠右越具体):
D.f() → super.f() = C.f() // super 跳到 MRO 下一个
C.f() → super.f() = B.f() // ⚠️ 不是 A.f()!在 D 的语境里 B 是 MRO 下一个
B.f() → super.f() = A.f()
A.f() → returns "A"
B prepends → "A->B"
C prepends → "A->B->C"
D prepends → "A->B->C->D"
实操记忆点:
- 菱形里
super不再”指向直接父”,而是”指向 MRO 下一个” is X, Y, Z这种声明,右边的越后被加进来、覆盖优先级越高- 当父合约里有多个同名 virtual 函数被多个继承链都覆盖时,子合约必须用
override(...)显式列出所有被覆盖的父——漏一个编译就拒绝
💡 回头看 B.2 的
MyToken is ERC20, Ownable:ERC20 和 Ownable 都隐式继承 OpenZeppelin 的Context(提供_msgSender()之类的 helper)——这就是个迷你菱形。Solidity 帮你把 MRO 算好,所以大多数时候你不用想这层。但读老合约 / 遇到 override 编译错的时候,你需要知道这套机制的存在。
A.3 · interface · 只写形状 · 不写实现
A.2 你写了一个具体的 Vault。但实战中你经常遇到这样的场景:
我想写一段代码——它不在乎你给我的是哪种 Vault。可能是 A.2 那个
Vault、可能是TimeLockVault、可能是别人写的MultiSigVault——我只在乎对方有stash()和balance()这两个函数能调。
interface 就是这个”我只在乎形状”的语法——它只规定函数签名,不规定实现:
◢ solidity · 点亮的关键字可点 / hover 看含义
IVault { function stash() external payable function balance() view returns (uint256) }
注意三件事,全都和”普通 contract”不一样:
- interface 里没有函数体——只有签名 + 分号
- 所有函数自动是
external——不能 internal / public / private - 不能有状态变量、不能有 constructor、不能
is一个普通合约
interface 真正的威力在于:它让你写”接受任何形状匹配的合约”的代码。看下面:
contract Auditor {
// 不在乎对方是哪个具体合约——只要它"is IVault"、形状对得上,就能调
function check(IVault v) external view returns (uint256) {
return v.balance();
}
}
调 auditor.check(myVault)、auditor.check(timeLockVault)、auditor.check(隔壁组刚写完的另一个 Vault)——Auditor 一行没改,但能审计 N 种 Vault。前提只有一个:传进来的合约得 is IVault(或形状对得上)。
类比 · interface = USB-C 端口——只规定形状(5 根针脚 + 物理轮廓),不规定背后是手机、显示器还是硬盘。任何形状对得上的设备插上来都能用。
💡 下一节 B.2 你会见到一个特别重要的 interface:
IERC20——一份只有 6 个函数签名的接口。任何实现了这 6 个函数的合约就是”一个 ERC-20 token”。USDC、DAI、你自己写的 MyToken——内部实现可以完全不一样,但只要都遵守IERC20这个形状,Uniswap / Aave / 你写的合约都能用同一段IERC20 token类型的代码和它们打交道。
interface 是 DeFi 可组合性(“乐高互拼”)的物理基础——L2 C.6 那张乐高城之所以能拼起来,就是因为大家都说”我兼容 IERC20”。(这个”形状插拔”的可视化等到 B.4 看完两种 token 标准之后再放出来——那时你才认得插头上的标签。)
A.4 · library · 工具箱
library SafeMath {
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
require(c >= a, "overflow");
return c;
}
}
contract MyContract {
using SafeMath for uint256;
function bump(uint256 x) public pure returns (uint256) {
return x.add(1); // 等价于 SafeMath.add(x, 1)
}
}
类比 · library 不是员工,是工具箱。员工(contract)有自己的办公室、自己的文件柜(state);工具箱(library)就是一堆纯函数,不能有自己的状态变量,但所有员工都能从里面拿工具用。
| contract | library | |
|---|---|---|
| 能有 state? | ✅ | ❌ |
| 能被部署? | ✅ | 视情况(internal-only library 内联进调用者) |
| 能继承? | ✅ | ❌ |
using ... for 语法 | ❌ | ✅ |
using SafeMath for uint256 这一行把 library 的函数挂到一个类型上——之后所有 uint256 变量都能直接 .add()、.sub()、.mul(),编译器自动转成 SafeMath.add(x, 1)。
💡 0.8 之后整数溢出自动 revert——SafeMath 在新代码里基本退役了,但库这个机制本身还在大量用于工具函数(
Strings、Address、SignedSafeMath等)。
🧪 顺便讲一个 CS 概念 · 纯函数 (pure function)
注意 SafeMath.add 上面那个修饰词:internal pure。这个 pure 不是 Solidity 自己发明的——它来自一个叫函数式编程的编程哲学。一个函数被称为”纯的”(pure),要同时满足两件事:
- 输入定输出 · 像数学课本里的 f(x) = x + 1,给 3 必返 4,永远。不会因为”现在是几点”、“链上谁有多少钱”、“今天天气怎样”而变。
- 不碰外面 · 不读 state、不写 state、不发 event、不调别的合约。它就是一个”输入进去→输出出来”的密封小盒子。
为什么这件事和 library 强相关?因为 library 自己没有 storage——它根本没东西可碰——所以它的函数天然就被推向 pure。SafeMath.add(3, 5) 永远是 8,无论你在哪个合约里调、无论区块号是多少、无论链上正在发生什么交易。
纯函数为什么值得追求:
- 可预测 · 同样输入永远同样输出 → 测试只要跑一遍样例就 100% 确定。这就是 Haskell / Lisp / 函数式语言推崇 pure 的最大理由
- 能放心组合 ·
add(mul(x, 2), 1)一串嵌套调用,不会有意外副作用——你不用担心”哎中间那个函数会不会偷偷改了什么” - 链下调用免费 · pure / view 函数没进打包交易时是不花一分 gas 的——节点本地算个结果给你就行(因为它们不改变链状态)
Solidity 的 pure 是编译期强制的承诺——你在 pure 函数里偷偷读了一次 balanceOf[...] 或者发了一个 event?编译直接拒绝。 这不是个标签,是一份”我保证不碰世界”的合同。
💡 L3 你见过
view(只读外部、不改)和pure(连读都不读)。现在你知道pure背后的范式来源了——它来自函数式编程那种**“用输入→输出映射搭世界、尽量少修改世界”**的哲学。从 Lisp、Haskell 到 Rust 的 immutability、再到 Solidity 的pure,都是同一个思路在不同语言里的化身。写智能合约的人尤其偏爱 pure:链上的代码一旦部署就不可改,越多东西能”输入→输出”地推导清楚,安全审计越好做。
模块 B · 标准代币 · ERC-20 + ERC-721
B.1 · 读真实的 ERC-20
ERC-20 是 ETH 上最重要的一份合约模板——USDC / USDT / WETH / UNI / 所有 DeFi 协议的内部代币,全都按这个模板写。学会读它,半个 DeFi 世界就读完了。
// OpenZeppelin ERC20.sol 核心
contract ERC20 {
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) public returns (bool) {
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
uint256 allowed = _allowances[from][msg.sender];
require(allowed >= amount, "insufficient allowance");
_allowances[from][msg.sender] = allowed - amount;
_transfer(from, to, amount);
return true;
}
}
_todo · 逐行注释:balances 是核心 storage、approve/transferFrom 双步授权模式(重要安全前提)、为什么是 mapping 不是 array
todo · 解释 ERC-20 的 6 个必需函数和 3 个必需 event · 总量、余额、转账、授权、查询授权
双步授权(approve + transferFrom) 是 DeFi 最常被绊倒的地方:你先批准 Uniswap 能花你的 USDC,再调 Uniswap 让它执行 transferFrom。这两步是分开的 transaction,中间任何错误都可能让 allowance 被错误利用。
B.2 · 自己写一个 ERC-20
生产里这是 8 行(继承 OpenZeppelin 的 ERC20 + Ownable):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// 真实项目里这两行是 import,Playground 不带 OZ 解析所以这里我们手写完整版
// import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// import "@openzeppelin/contracts/access/Ownable.sol";
contract MyToken is ERC20, Ownable {
constructor() ERC20("MyToken", "MTK") Ownable(msg.sender) {}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
💡 注意
constructor(...) ERC20("MyToken", "MTK") Ownable(msg.sender)这个语法——这是 Solidity 调用父合约 constructor 的方式。L3 教过 constructor 部署时跑一次,但多重继承时每个父类的 constructor 都要在子类里手动唤醒。
Playground 里我们写展开版——把 ERC20 内部的 state 和函数全部摊开,让你逐行看清楚 _balances / _allowances 这些 mapping 在每次操作时怎么变。生产里你只要继承 OpenZeppelin,但你必须先理解你继承的是什么。
4 步验收(每一步在 Playground 里跑一遍,看 inline 结果跟你想象的对不对):
| 步 | 操作 | 期望看到 |
|---|---|---|
| 1 | compile + deploy(你 Alice 就是 owner) | banner 显示合约地址 |
| 2 | mint(self, 1000) —— 给自己铸 1000 MTK | output: Transfer(0x0, self, 1000) event · 调 balanceOf(self) → inline = 1000 |
| 3 | transfer(Bob, 200) —— 直接给 Bob 转 200 | output: Transfer(self, Bob, 200) · balanceOf(self) = 800 · balanceOf(Bob) = 200 |
| 4 | approve(Charlie, 100) —— 授权 Charlie 最多花你 100 | output: Approval event · allowance(self, Charlie) = 100 |
第 4 步只是签字,钱没动。Charlie 现在有权从你这里 transferFrom 最多 100——但实际拉钱是 B.3 才会演示的事。
🔌 顺手把 IERC20 拿出来 · 给你刚写的 MyToken 套一个 interface
你刚才在 MyToken 里写了 6 个对外的能力 + 2 个 event——这些不用再解释,你刚自己跑过:
totalSupply·balanceOf·allowance—— 三个public状态变量,编译器自动生成 gettertransfer·approve·transferFrom—— 三个 external 函数Transfer·Approval—— 两个 event
现在做一件机械的事:把每个函数的函数体 { ... } 删掉换成分号;把 state 变量、constructor、modifier 全删掉;把 contract 改成 interface。剩下的就是 IERC20:
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint256 amount);
}
重点:这份 IERC20 一个字都没新加——它纯粹是把你刚才那份 MyToken 的”对外形状”剥出来。函数名、参数、返回值,全都和你写的一模一样。
为什么要做这件看起来”重复”的事?因为我们想要一个类型,用来声明”任何一份符合这个形状的 token”。下一节 B.3 你会写另一台合约(VendingMachineToken),它要调你 MyToken 上的函数——可如果它写 MyToken public token,就和 MyToken 一对一绑死了;明天换成 USDC、DAI 就不行。写 IERC20 public token 才能”接任何一份 ERC-20”——回看 A.3 那个 IVault v 让 Auditor 能审计任何一种 Vault 的套路。
contract vs interface · 现在你写过两种了:
| B.1 / B.2 写的 contract | 这里的 interface | |
|---|---|---|
| 关键字 | contract | interface |
| 内部 | 有 state、有函数体、能 mint/转账 | 只有签名 + 分号 · 没实现 |
| 部署 | 部署成一个地址 · 那地址里有 token 余额 | 不部署 · 它只是一个类型 |
| 用途 | ”我是一份 ERC-20" | "我这个变量指向任何一份 ERC-20” |
USDC、DAI、你的 MyToken——都是一份具体的 ERC-20 contract,内部实现可以完全不同(OpenZeppelin 版、Vyper 版、汇编手写版……);但它们都符合同一个 IERC20 形状——所以一个 IERC20 token 变量可以指向它们当中任何一个。
💡 下一节 B.3 开头会出现这一行:
IERC20 public token;——它就是”接任何 ERC-20”的入口。Deploy 时传进 MyToken 的地址,机器就和那份 MyToken 绑定;换成 USDC 的地址,机器就和 USDC 绑定。同一份代码、不接同一个 token——这才是它的价值。
B.3 · 立刻收一笔钱 · ERC-20 付费的标准流程
MyToken 写完了——但 token 不只是”发出来给用户拿着”,它更重要的用法是被另一个合约用作付款。
我们把 L3 那台 VendingMachine 拿回来——同一个商品(一瓶汽水)、同一台机器——只把支付方式从 ETH 换成 MyToken。Web2 出身的开发者第一反应是”transfer 嘛、调一下就完了”。但接下来你会撞到一堵墙——这堵墙背后是 Solidity 一个最基础、Web2 出身的人最容易忽视的物理规则:合约的执行环境。理解清楚它,不止能解释 ERC-20 为什么这样设计——后面 C.3 讲 call vs delegatecall 也是同一套思路。
还记得 L3 V9 的 VendingMachine 吗?
buySoda() payable+require(msg.value >= sodaPrice)——用户带着 ETH 来一笔 tx,就完了。下面这台机器换成收 token,乍看就是改一行——但那一行不会工作。先看是什么样的”看似很对”。
第一次尝试 · Web2 直觉版(看似很对 · 但跑不通)
机器合约第一行就是 B.2 末尾预告过的那一行——IERC20 public token——意思是”我接受任何符合 IERC20 形状的 token”,deploy 时绑定一个具体地址(MyToken / USDC / DAI 都行)。然后 buySoda 凭直觉,“把 ETH 换成 token”大概就是把 msg.value 那行换成调一下 token.transfer——“把 sodaPrice 个 token 转给机器自己”嘛:
// ❌ 直觉版 · 看起来读起来都对 · 但完全跑不通
contract VendingMachineToken {
IERC20 public token; // ← B.2 预告的那行 · 接任何 ERC-20
uint256 public sodaPrice = 100;
uint256 public sodaCount = 100;
constructor(address _token) { token = IERC20(_token); }
function buySoda() external {
if (sodaCount == 0) revert SoldOut();
token.transfer(address(this), sodaPrice); // ← "把 sodaPrice 个 token 转给本合约"?
sodaCount -= 1;
bought[msg.sender] += 1;
emit SodaSold(msg.sender, sodaPrice);
}
}
token.transfer(address(this), sodaPrice) 读起来就是 “转 sodaPrice 个 token 给 本合约”——文字意思无可挑剔。但它一行不会从 Alice 那里扣到任何钱——它甚至会直接 revert。
要看清为什么,必须先搞清 Solidity 一件特别基础、Web2 出身的人最容易忽视的事:在每次跨合约调用时,msg.sender 都会换人。
🔍 合约的执行环境 · 代码 + 存储 + 消息
退一步先讲个普通程序的事。任何一段代码在执行时,都需要两件东西:
- 代码 —— 这次执行的指令在哪里
- 存储 —— 这次执行读写的数据在哪里
C / Python / Java 写一个函数时,“代码”是 .text 段、“存储”是堆栈和堆。Solidity 也一样:一个合约 = 一份代码(部署时定死、不可改)+ 一份存储(key-value 表 · 链上持久化)。这两件东西绑死——你说”我调 Machine 合约”,意思是同时拿到了 Machine 的代码和 Machine 的存储。
区块链上的调用比普通函数调用多一件事:它是一封消息。所以合约的执行上下文 = 代码 + 存储 + 消息:
- 消息 —— 区块链特有 · 内含
msg.sender(谁发起的这一跳)、msg.value(这一跳带了多少 ETH)、address(this)(当前合约自己的地址)等
每次跨合约调用,就问三个问题:
| 问题 | 答案(普通 call) |
|---|---|
| 用谁的代码? | 被调合约的代码 |
| 读写谁的存储? | 被调合约的存储 |
| 消息里写什么? | 一封新消息:msg.sender = “上一跳的发起方”(多半是当前合约自己)· msg.value = 这一跳带的 ETH |
也就是普通 call 把三件事同时换一套。这条规则里有一个反直觉的角落,专门坑 Web2 出身的人:
msg.sender永远是”上一跳调我的那个”——不是”最开始发起 tx 的那个用户”。
下面这个动画把 Alice 调 Machine.buySoda() 这一笔的实际跳转可视化——三列对应三个合约/账户,下面那个面板对应代码 / 存储 / 消息三件套。按 next → 一步一步走,每跳一次看哪几格在变:
◆ execution context · 一跳一信 · 看每封消息里的 from 是谁
0xA1ce…
外部账户 (EOA) · 无代码
wallet · 链上持久化
Alice 即将寄出第一封消息 · 点 next ↗
当前执行上下文 = 代码 + 存储 + 消息
代码 · code
这次执行用谁的指令
(空)
存储 · storage
读写谁的 key-value 表
(空)
消息 · message
区块链特有 · 上一跳那封信
初始 · Alice 钱包里有 1000 MTK · Machine 刚 deploy 完、还没 MTK · 点 next → 让 Alice 发起调用。
delegatecall 是例外——只换代码、存储和消息保留。——重点看 phase 2 那一跳:Machine 内部调 token.transfer(...) 时,面板上三件套同时切换:
- 代码 Machine → MyToken
- 存储 Machine → MyToken
- 消息里
msg.sender从 Alice 变成 Machine —— 因为这一跳是 Machine 发起的,不是 Alice
回看你 B.2 写的 transfer 实现:
function transfer(address to, uint256 amount) external returns (bool) {
require(balanceOf[msg.sender] >= amount, "not enough"); // ← 扣的是 msg.sender 的余额
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
...
}
所以那行 token.transfer(address(this), sodaPrice) 在 MyToken 里跑起来时——它扣的是 Machine 自己的余额。Machine 才刚 deploy、根本没有 MTK——require(balanceOf[msg.sender] >= amount) 立刻失败、revert。
这不是逻辑 bug——是上下文物理规则的直接后果:msg.sender 在新上下文里就不是 Alice 了,所以 transfer 这个”以 msg.sender 为付款人”的函数没法用来代用户付款。后面 C.3 讲的 delegatecall 反过来——它借用别人的代码、但保留当前上下文(msg.sender / address(this) / storage 都不换)。理解了”上下文是什么”,到时那个反转才有依托。
修复 · ERC-20 为此专门有个第二个函数 transferFrom
既然 transfer 用的是 msg.sender(= 调用者自己的余额),而我们要的是”扣 Alice 的余额”——而 msg.sender 不能凭空变成 Alice——ERC-20 设计者当年面对的就是这个问题。他们的答案:加一个第二个函数 transferFrom,把”从谁那里扣”作为显式参数传进去:
function transferFrom(address from, address to, uint256 amount) external returns (bool);
// ^^^^^^^^^^^^ 显式说明"从谁那里扣" · 不再依赖 msg.sender
马上有新问题:任何人都能调 transferFrom 扣别人的余额吗? 不行——那 ERC-20 就成开放钱包了。所以标准规定:transferFrom 必须先检查 allowance——from 这个人必须事先调一次 approve、明确授权 msg.sender(也就是调用 transferFrom 的人)能扣多少。
于是 buySoda 正确的写法:
// ✅ 修复版
function buySoda() external {
if (sodaCount == 0) revert SoldOut();
token.transferFrom(msg.sender, address(this), sodaPrice);
// ^^^^^^^^^^ 这里 msg.sender = Alice(buySoda 函数体内的环境)
// 把它显式抓住、作为 from 参数传进去
sodaCount -= 1;
bought[msg.sender] += 1;
emit SodaSold(msg.sender, sodaPrice);
}
注意同一个变量名 msg.sender 在两层函数体里值不一样:在 buySoda 里它是 Alice、在 transferFrom 里它是 Machine。所以 buySoda 必须把 Alice 这个值当场抓住、当参数传下去——这就是 Solidity 跨合约编程一个无处不在的模式。
所以 ERC-20 收款一定是这 2 步——它不是”标准多此一举”,是 msg.sender 的物理规则逼出来的:
Alice → token.approve(Machine, 100)—— Alice 直接打到 MyToken,msg.sender = Alice,所以allowance[Alice][Machine] = 100能正确记录”是 Alice 授权的”Alice → Machine.buySoda() → token.transferFrom(Alice, Machine, 100)—— 两跳之后 transferFrom 里 msg.sender = Machine;但 transferFrom 查allowance[Alice][Machine] ≥ 100、有授权——扣钱成功
所有 DeFi 协议(Uniswap 兑换、Compound 存款、Aave 借贷)的入金路径都长这样——因为 msg.sender 在跨合约时换人,所以必须有”显式 from + 事先 approve”的 dance。
在 Playground 里跑一遍这场 debug
下面这个 Playground 比之前的复杂一点——它里面同时装着两份合约:你 B.2 写过的 MyToken,以及新写的 VendingMachineToken。编译后 deploy 下拉框会列出两个候选,你分别部署它们,分别调用它们的函数;中间还有一个 deployed 栏让你切换”现在调谁”。
而且 VendingMachineToken 里 buySoda 故意装着错的直觉版(用 token.transfer)。按下面的脚本跑,第 4 步你会亲眼撞到 revert——感受一下”msg.sender 跨合约换人”这件事——然后第 5 步改一行代码、重新部署,第 8 步终于跑通。
8 步调试脚本 · 整个流程都在这一个 Playground 里完成——靠顶部 deploy: ▾ 切换部署目标、靠中间 deployed ○ MyToken ◉ VendingMachineToken 切换调用目标:
| 步 | 切到 | 操作 | 关键观察 |
|---|---|---|---|
| 1 | deploy: MyToken | 点 ▶ compile → 点 ⬢ deploy(无构造参数) | 顶部出现 banner · 这是 MyToken 的地址 · 设为 A · 用 ◆ copy 把它复制到剪贴板 |
| 2 | deployed ◉ MyToken | 调 mint(self, 1000) —— 给自己铸 1000 MTK 当购物预算 | Transfer(0x0, self, 1000) event · 调 balanceOf(self) → = 1000 |
| 3 | deploy: VendingMachineToken | 切下拉到 VendingMachineToken · 出现一个 address _token: 输入框 · 把剪贴板里的 A 粘贴进去 · 点 ⬢ deploy | banner 切到 VendingMachineToken 地址 · 设为 B · 中间的 deployed 栏里出现两个 chip:○ MyToken (A) · ◉ VendingMachineToken (B) |
| 4 | deployed ◉ VendingMachineToken | 直接调 buySoda() | 🔥 × revert "not enough" —— 正是”执行环境”那节预言的现象:在 MyToken.transfer 里 msg.sender = Machine、扣 Machine 自己的余额(0)→ 失败 |
| 5 | 编辑器 | 把 token.transfer(address(this), sodaPrice) 注释掉 · 启用下面 token.transferFrom(msg.sender, address(this), sodaPrice) · 点 ▶ compile | compile ok · 但 EVM 上旧 B 还在 · 还没新版 |
| 6 | deploy: VendingMachineToken | _token 输入框里 A 还在(保留草稿)· 点 ⬢ deploy 第二次 | 新的 VendingMachineToken 地址 B’ · 中间 deployed 栏现在有 3 个 chip:○ MyToken · ○ VendingMachineToken(旧 B) · ◉ VendingMachineToken(新 B') |
| 7 | deployed ○ MyToken | 切回 MyToken · 调 approve(B', 100) —— 要授权新的 B’,不是旧的 B(◆ copy B’ 的地址过来用) | Approval event · allowance(self, B') = 100 |
| 8 | deployed ◉ VendingMachineToken (B') | 切回 B’ · 再调 buySoda() | ✓ SodaSold(self, 100) · sodaCount() = 99 · bought(self) = 1 · vaultBalance() = 100 |
第 4 步那个 revert 是这一节最重要的”AHA 时刻”——不是”忘了 approve”那种表面错,是 msg.sender 在跨合约时换人这条物理规则把整个 ERC-20 收款的形状定死了:
- transfer 用 msg.sender 当源头 → 跨合约后源头就变成中间合约自己 → 没法用来”代用户付款”
- 所以必须有 transferFrom(显式 from)+ approve(事先授权)这套机制
记住”执行环境”这把钥匙——B.4 之后的合约调合约、C.3 的 delegatecall、D.2 的访问控制,全都是它的不同侧面。
🔥 真实事故 · ERC-20 approve race condition
这个双步设计本身埋着一个臭名昭著的坑:
Alice 给 Bob 授权了 100 token(
approve(bob, 100))。后来想改成 50,于是发approve(bob, 50)。但 Bob 看到了 mempool 里的这条 tx,抢先打包transferFrom(alice, 100)用掉旧 allowance。然后 Alice 的approve(50)落账。Bob 再来一次 transferFrom(alice, 50) —— 总共拿走 150,而不是 Alice 想给的 50。
这就是 ERC-20 race condition。修复有两种思路:
- “先归零再设值”:
approve(bob, 0)→approve(bob, 50)。但归零的瞬间 Bob 还有机会抢跑——理论上仍然不安全。 - 原子操作:
increaseAllowance(bob, X)/decreaseAllowance(bob, X)——链上的工作量是不可分割的”在旧值基础上加/减 X”,没有”读旧值再写新值”的窗口。OpenZeppelin 现在的ERC20.sol把这俩作为推荐 API。
更激进的修复:很多新协议干脆改用 EIP-2612 permit(用签名替代 approve tx),让 approve 和 transferFrom 合并成一笔上链——race window 直接消失。USDC / DAI / aave 等大头都支持。
⚠️ 写 ERC-20 相关代码时 永远不要假设 approve 后没人能抢跑。这个假设破灭过太多次。
B.4 · ERC-721 · NFT · 同样的形状
声称”ERC-721 和 ERC-20 形状惊人地像”——口说无凭。把两份 interface 摆在一起,按 查询 / 转账 / 授权 / event 分类对比:
// IERC20 (你 B.2 末尾刚剥出来的) ──────────────────
interface IERC20 {
// 查询
function balanceOf(address account) external view returns (uint256);
function totalSupply() external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
// 转账
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
// 授权
function approve(address spender, uint256 amount) external returns (bool);
// event
event Transfer(address indexed from, address indexed to, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint256 amount);
}
// IERC721 (NFT 标准) ────────────────────────────────
interface IERC721 {
// 查询
function balanceOf(address owner) external view returns (uint256);
function ownerOf(uint256 tokenId) external view returns (address); // ⬅ 新增
// 转账
function transferFrom(address from, address to, uint256 tokenId) external;
// 授权
function approve(address to, uint256 tokenId) external;
function getApproved(uint256 tokenId) external view returns (address);
function setApprovalForAll(address operator, bool approved) external; // ⬅ 新增
function isApprovedForAll(address owner, address operator) external view returns (bool);
// event
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
}
99% 一样、1% 不同——但那 1% 把 token 的”性格”彻底改了:
- 名字照搬 ERC-20:
transfer/approve/transferFrom/balanceOf/Transfer/Approval—— 完全是同一套 - 唯一真改动是第二个参数的语义反了:ERC-20 的
uint256 amount是”多少”·ERC-721 的uint256 tokenId是”哪一个”。类型相同、含义彻底相反 - 新增
ownerOf(tokenId)—— 每件 NFT 都有唯一所有者,能反查”这件归谁”(ERC-20 不需要 · 谁会问”这个 wei 归谁”?) - 新增
setApprovalForAll(operator, true)—— “把我所有 NFT 一次性授权出去”。OpenSea 上 list 全部藏品就是这一行——而不是给每件 NFT 单独 approve
下面的 widget 把两份 storage 并排——点 next → 看 mint 和 transfer 时谁动了哪一格、再次确认”形状一样、主键反过来”:
◆ ERC-20 vs ERC-721 · storage 布局对比
初始
mint · 铸造
transfer · 转账
两边 storage 都是空 · 还没有任何 token
ERC-20 · MyToken · 主键 = 地址
(还没 mint · mapping 是空的)
ERC-721 · MyNFT · 主键 = tokenId
(还没 mint)
(同上)
两边 storage 都还是空 mapping。注意主键的类型差异——左边 key 是 address,右边 key 是 uint256(tokenId)。 这就是 fungible vs non-fungible 在 storage 层的物理体现。
🔌 一个真案例 · Uniswap 凭什么能给”上千种 token 对”做兑换
A.3 讲过 interface 是”对形状的契约”——但靠 Vault 这种小例子很难真切感受到它有多大力。下面把 Uniswap 拿来当例子——它的核心其实就是一个最小的 SimpleSwap 合约,两个槽都是 IERC20 类型。点选 tokenA 和 tokenB 看哪些组合能跑、哪些会 revert——亲眼看 interface 怎么让”一份代码服务无限 token 对”成为可能:
◆ Uniswap 的本质 · 一份代码 · 任意两种 ERC-20 都能换
❶ Uniswap 想解决的问题
用户想用 USDC 换 DAI——但 USDC 合约不知道 DAI 怎么转账, DAI 合约也不知道 USDC 怎么转账。Uniswap 想写 一份"中间人"合约, 能处理 任意两种 token 的兑换。 关键问题:怎么"任意"?——答案:只要两个 token 都符合 IERC20 形状,中间人合约就能调它们。
❷ 最小可工作的 SimpleSwap 伪代码
contract SimpleSwap {
IERC20 public tokenA; // 槽 A · 任何符合 IERC20 形状的 token 都能插
IERC20 public tokenB; // 槽 B · 同上
constructor(address _a, address _b) {
tokenA = IERC20(_a);
tokenB = IERC20(_b);
}
// 用 amountIn 个 tokenA 换 tokenB
function swap(uint256 amountIn) external {
tokenA.transferFrom(msg.sender, address(this), amountIn);
uint256 amountOut = getRate(amountIn); // 定价省略
tokenB.transfer(msg.sender, amountOut);
}
}注意两件事:tokenA / tokenB 的类型都是 IERC20——SimpleSwap 不在乎对方到底是 USDC、DAI、还是别的—— 只要符合形状就能调 transferFrom 和 transfer。
❸ 点选 tokenA + tokenB · 看哪些能插进去
结果 · `new SimpleSwap(USDC, DAI)`
✓ SimpleSwap(USDC, DAI) deploy 成功
两个槽都是 IERC20 形状 · swap 能正常 transferFrom + transfer · 任何人可以发 tx 用 USDC ↔ DAI 互换 · 代码一字未改。
模块 C · 合约 ↔ 合约
C.1 · 合约调合约 · 两大主线 · call 和 delegatecall
合约 A 想触发合约 B 上的某段代码,实质上只有两种调用方式:
call—— 普通调用 · “派人去 B 公司办事” · 用 B 的代码 + B 的存储 + 新构造的消息delegatecall—— 委托调用 · “借 B 的脑子在自己家里跑” · 用 B 的代码、但留在 A 的存储 + A 的消息
两者的差别只在执行上下文里”代码”和”存储”两个槽的归属——回看 B.3 的 🔍 执行上下文 那张图。下面的 widget 把 A 和 B 摆出来,让你切两种模式、点 ▶ A.poke(),亲眼看 x += 1 这一行最后写到了谁的 storage:
◆ A 调 B · call vs delegatecall · 三件套都看
0xA1ce…
0xAaaa…
code
function poke() {
b.call(...);
}storage
✉ message
foo()
A.call(B)
0xBbbb…
code · ← 这次跑的
function foo() {
x += 1;
}storage · ← x += 1 写的是它
⚡ B.foo() 跑的时候 · 执行上下文长这样
代码 · code
谁的指令在跑
B
存储 · storage
读写谁的状态
B
msg.sender
上一跳的发起方
0xAaaa…
address(this)
当前合约自己
0xBbbb…
call 模式 · 四件全是 B 的(除了 sender 是 A)· 普通的"派人去 B 公司办事"—— 在 B 的办公室、用 B 的文件柜,对外说"我是 B(address(this) = B)"。
delegatecall 不是 call 的小变体——它是一条完全不同的物理规则。同一份 B.foo() 的代码,用 call 调改 B 的 x;用 delegatecall 调改 A 的 x。这就是为什么 proxy 升级模式存在(D.1)、也是为什么 storage layout 必须严格对齐(C.3 会拆这个坑)。
💡 小注:转 ETH 的
.transfer / .send—— 早期 Solidity 还有这俩专门转 ETH 的方法,硬编码 2300 gas。EIP-1884 之后 2300 不够用了,现代实践用addr.call{value: x}("")替代(配合重入保护,L5 会讲)。能识别这俩是为了读老代码——新代码别再写。
C.2 · 三种地址代词 · address(this) · msg.sender · tx.origin
C.1 看完调用方式的”代码 + 存储”两个槽,这一节聚焦消息里的”地址类”代词。Solidity 提供三个长得像但语义天差地别的代词:address(this)、msg.sender、tx.origin——搞混任一个都可能直接出安全事故。下面这个 widget 分两步教:
- 第 1 步 · 地址名册 —— 看清楚链上各个 actor 都是谁(Alice + 3 份合约 A/B/C),讲清楚
address(this)不是”调用者”也不是”原始用户”,它只跟当前正在跑的代码走。 - 第 2 步 · msg.sender —— 让 Alice 真正发起一笔调用,走
Alice → A → B → C这条链,看msg.sender每跳换人、tx.origin永远不变。结尾给出 tx.origin 钓鱼攻击的安全要点。
◆ 三种地址代词 · 走一遍 Alice → A → B → C
第 1 步:先看清楚 actor 都是谁。 这条链上有 1 个 EOA(Alice)+ 3 份合约(A、B、C)。每份合约部署时拿到一个**永久不变**的地址—— 就像身份证号。address(this) 不是"调用方"也不是"原始用户"——它**始终等于"当前正在跑的那份代码所属合约"的地址**。 按 next → 让"运行权"从一份合约传到下一份,看 address(this) 怎么跟着代码走。
0xA1ce…
0xAaaa…
0xBbbb…
0xCccc…
phase 0 / 3
当前 address(this) 是谁
address(this)
(没在跑 · 点 next →)
= 当前正在跑的代码所属合约
C.3 · delegatecall 的真实代价 · storage slot 必须对齐
C.1 你已经看到 delegatecall 的本质——借 B 的代码、用 A 的存储。听起来挺优雅。但实战里它埋着一个反复让人栽跟头的坑:B 的代码看的是物理 slot 编号、不是变量名。B 以为 “slot 0 是 uint x”——但写出去时砸进的是 A 的 slot 0,而 A 的 slot 0 可能是个 address admin。
下面的 widget 沿用 C.1 的布局(Alice → A → B + 执行上下文面板),但多了一个布局切换:A 的 slot 0 在两种声明下试同一笔 B.setX(42) delegatecall。看看它什么时候是无害写入、什么时候直接把 admin 改成 0x000…002A:
◆ 同一笔 delegatecall · 看 A 的存储布局怎样改变结局
0xA1ce…
0xAaaa… · (Proxy · 拥有 state)
code
function poke() {
b.delegatecall(
abi.encodeWith
Signature(
"setX(uint256)",
42
)
);
}storage · ← 写到的是这里
= 100
= 0xOwn…r
✉ message
borrow setX
A.delegatecall(B)
0xBbbb… · (Logic · 提供代码)
code · ← 这次跑的
// 这是 B 的代码
function setX(uint256 newX) {
x = newX; // 写 slot 0
}storage
= 999
= 0xLogic…
⚡ B.setX(42) 跑的时候 · 执行上下文(delegatecall · 借代码不换家)
代码 · code
跑的是 B.setX
B 的
存储 · storage
写的是 A 的 slot 0
A 的
msg.sender
沿用 A 的上一跳
Alice
address(this)
不换 · 仍是 A
A
这就是 Proxy 升级模式有铁律的原因:新版 Logic 的 storage layout 必须和 Proxy 严格对齐——新增字段只能加在末尾、不能在中间插、不能调换顺序。OpenZeppelin 的 Upgradeable 系列就靠这条规则编译期保证安全。
⚠️ 2017 年 Parity Multisig 永久冻结 50 万 ETH —— 元凶就是 delegatecall + 一个 library 合约里的
selfdestruct。攻击者通过 delegatecall 触发 library 自毁,所有依赖它的钱包瞬间变砖。L5 会专门拆这个案子。
🔬 进阶 · 嵌套调用栈 · A.delegatecall(B) 之后 B.call(C),C 看到的 msg.sender 是谁?
现在升一档难度。考虑这条 3 层调用路径:
contract A {
function execute() external {
// 用 delegatecall 借 B 的代码
b.delegatecall(abi.encodeWithSignature("doSomething()"));
}
}
contract B {
function doSomething() external {
// B 的代码内再调 C —— 注意这是普通 call
c.logEvent();
}
}
contract C {
function logEvent() external view returns (address) {
return msg.sender; // ← 这一行返回的是谁?
}
}
Alice → A.execute() → A 通过 delegatecall 借 B 的代码 → 这段(“B 的”)代码再用普通 call 调 C.logEvent()。问:C 看到的 msg.sender 是 B 还是 A?
直觉答案是 B(毕竟”是 B 的代码在调 C”)。但正确答案是 A。下面这个 widget 把整个 3 层调用栈摊开,让你按 next ▼ 一帧一帧推、亲眼看每一帧的 code / storage / msg.sender / address(this)。切到 “② delegate → call” 模式看反直觉的那一刻:
◆ 高级 · 3 层调用栈 · A → B → C
0xA1ce…
外部账户
无代码
无 storage
0xAaaa… · (Proxy · 拥有 state)
code
function execute() {
b.call(
"doSomething()"
);
}storage
0xBbbb… · (Logic · 提供代码)
code
function doSomething() {
c.call(
"logEvent()"
);
}storage
0xCccc… · (被调 · 只看 msg.sender)
code
function logEvent() {
emit Log(
msg.sender
);
}storage
phase 0 / 3
⚡ 当前帧 · 执行上下文(空 · 还没开始)
代码 · code
谁的指令在跑
—
存储 · storage
读写谁的状态
—
msg.sender
上一跳
—
address(this)
当前合约
—
初始 · Alice 准备调 A.execute() · 点 next → 走第一步。
为什么 C 看到的是 A? 关键在 frame 2 这一帧——delegatecall 把 B 的代码塞进 A 的上下文 ·
frame 2 里 address(this) = A(delegatecall 没换 this)。当这一帧再向外发 call 时,EVM 用”当前帧的 address(this)”来填被调方的 msg.sender——所以 frame 3 (C) 收到的 sender 就是 A。B 的代码在调,但发出 call 的”身份”是 A。
这条规则正是为什么 Proxy 模式下要写”调用外部合约”的代码必须万分小心——你以为代码长在 logic 合约里、对外身份就是 logic,错了,对外身份是 Proxy(A)。所有 EIP-2612 permit、ERC-4626 vault、UUPS 升级模式里都埋着这条规则的影子。
模块 D · 升级和权限
C.1–C.3 你学了一整套 delegatecall 的物理规则——但 delegatecall 在生产实战里到底是用来干什么的?答案就是 proxy 升级模式:链上代码部署后不可改、但业务一直在演化——所以行业把”代码”和”状态”拆成两份合约,用 delegatecall 把它们粘起来。这是 Module C 那套机制最直接、最重要的应用。先看升级模式(D.1),再回过头看”谁有权升级”——这就引出访问控制(D.2)。
D.1 · upgradability · proxy pattern · 分离逻辑和状态
合约部署到链上之后代码不可变——但业务需求会变、bug 要修。proxy pattern 的思路:把”代码”和”状态”分到两份合约里——状态住 Proxy(永远不变的用户入口),代码住 Logic(可被替换);两者通过 C.1 的 delegatecall 串起来。换 Logic 合约 = 升级,但用户调的还是同一个 Proxy 地址、state 永远在 Proxy 里不动。
下面的 widget 是一个完整的 5 步升级生命周期演示(初始 → 部署 → 用户调 v1 → admin 升级 → 用户调 v2)·
沿用 C.1 / C.3 的盒子 + 线条视觉语言:每个合约都有 code + storage 两个面板、用 call 实线 / delegatecall 虚线连接·
Proxy 的 storage 里 impl / admin / counter 三个槽全程可见。
按 next → 一步一步走,特别盯 phase 3 升级时刻:Proxy.impl 翻转 v1 → v2,counter / admin 一动没动。然后 phase 4 你会看到同一笔 bump() 在升级前后行为完全不同(v1 +=1 vs v2 +=2)。
◆ proxy 升级模式 · 同一个地址 · 换代码 · 不丢 state
初始
部署
用户调 bump
admin 升级
用户再调 bump
▸ phase 0 · 初始
链上还没东西 · 点 next ▶ 开始部署
—
等下一步
— · 用户唯一入口
code
fallback() {
impl.delegatecall(
msg.data
);
}storage
— · 老版逻辑
code
function bump()
external {
counter += 1;
}— · 新版逻辑
code
function bump()
external {
counter += 2;
}初始 · 三个合约还没部署 · admin 已经准备好 Proxy + Logic v1 + Logic v2 的字节码 · 点 next → 开始部署。
主流标准:UUPS 和 Transparent Proxy(OpenZeppelin 提供)· Diamond Standard (EIP-2535) 做更细粒度的功能分片。所有这些标准都建立在 C.3 的 storage 对齐铁律 之上——新版 Logic 的 storage layout 必须和 Proxy 严格匹配。
💡 可升级 = 一份”会变的代码” —— 这意味着”项目方/admin 有能力悄悄改你正在用的协议代码”。可升级合约 ≠ 没风险——许多 DeFi 灾难本质上是 admin 权限被滥用,不是代码 bug。这就引出下一个问题:谁有权升级?
D.2 · 访问控制模式 · 谁有权升级 / 谁有权动钱
D.1 一句话留了个尾巴:“谁有权升级”。这不光是升级的问题——所有合约里”谁有权 mint / 谁有权动钱 / 谁有权改参数”都是同一个问题。L3 你见过 onlyOwner modifier——那只是最简单的版本。现实里有三种主要模式,演进路径几乎就是项目从 hackathon 走到主网的真实节奏。切 3 个 tab、再点 “触发攻击” 看每种模式被偷一个私钥时会怎样:
◆ 3 种访问控制 · 切换看每种"被偷钥匙"会怎样
code · 这个模式长什么样
高风险contract MyVault is Ownable {
function withdraw() external onlyOwner {
payable(owner).transfer(address(this).balance);
}
}信任模型
只有 owner 一个 EOA · 部署者通常就是 owner
什么阶段用
原型 / 单人项目 / token 部署前。⚠️ 上主网前必须升级到 multisig。
🔥 攻击场景 · 偷到 1 个私钥
点 "触发攻击" 看攻击者拿到一个签名者私钥后能搞多大破坏
模块 E · 实战
E.1 · 在 Playground 里串一个 Vault + Staker
目标 · 写两份合约:
StakeToken· 一个简单的 ERC-20(继承 OpenZeppelin),代表”质押凭证”Staker· 一个 Vault 合约——接收用户存入的StakeToken、按时间发放奖励、允许withdraw
需要用到的乐高:
- ✅ 继承 ·
StakeToken is ERC20, Ownable - ✅ interface ·
Staker通过IERC20接口和任意 ERC-20 token 对话 - ✅ library ·
using SafeERC20 for IERC20处理一些非标准 token 的兼容 - ✅ ERC-20 ·
StakeToken本身就是 - ✅ 访问控制 ·
Staker.setRewardRate只能 owner 调 - (ERC-721 这一课作为可选拓展 · 把 Vault 收据做成 NFT)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
contract Staker {
IERC20 public stakingToken;
mapping(address => uint256) public stakedAmount;
mapping(address => uint256) public stakedAt;
constructor(address _token) {
stakingToken = IERC20(_token);
}
function stake(uint256 amount) external {
stakingToken.transferFrom(msg.sender, address(this), amount);
stakedAmount[msg.sender] += amount;
stakedAt[msg.sender] = block.timestamp;
}
function withdraw() external {
uint256 amount = stakedAmount[msg.sender];
require(amount > 0, "nothing staked");
stakedAmount[msg.sender] = 0;
stakingToken.transfer(msg.sender, amount);
}
}
todo · 验收 · ①两份合约都部署成功 ②mint 给自己 1000 token ③approve Staker 花 500 ④stake(500) ⑤withdraw · 看 5 步是否都成功
todo · 进阶挑战 · 加一个 rewardRate · 按 (now - stakedAt) * rate 给奖励
🎬 收尾 · 6 块乐高 ✓ · 但还没人审过
读到这里,你应该能:
- 看懂
contract X is Y, Z的继承链 - 通过
interface和任意未知合约对话 - 部署一个自己的 ERC-20 / ERC-721
- 在三种调用方式间正确选择
- 理解 proxy 升级背后的 delegatecall 物理基础
- 拼出一个最小可用的 staking 协议
6 块乐高拼齐了 · 你已经能写一个 DeFi 协议雏形。
但⋯⋯你写的合约有没有被审过?
L3 末尾我们提到 TipJar 的 withdraw 那行有安全隐患——这一课的 Staker 里同样埋着至少三个攻击面:
withdraw里如果 token 是 ERC-777(带回调),可能被重入攻击stakedAmount[msg.sender] = 0写在 transfer 之后还是之前,决定了你能不能被掏空- 如果 admin 是单签的 EOA,私钥被盗 = 协议被盗
L5 我们专门讲这些。 不会写代码不可怕——可怕的是写出了能跑、能上线、能管钱的代码,却不知道哪里能被人攻穿。
思考题 · 翻回去看 Staker 的
withdraw函数。如果攻击者部署一个恶意 ERC-20,让transfer里反过来调Staker.withdraw(),会发生什么?