◢ Lesson 4 ⏱ 105 min

Solidity · 让合约工作

🎬 开场 · 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 块乐高」

lego 1 · 继承🧬is · super · override
lego 2 · 端口🔌interface
lego 3 · 工具🧰library · using … for
lego 4 · 代币🪙ERC-20
lego 5 · NFT🖼️ERC-721
lego 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"

实操记忆点

  1. 菱形里 super 不再”指向直接父”,而是”指向 MRO 下一个”
  2. is X, Y, Z 这种声明,右边的越后被加进来、覆盖优先级越高
  3. 当父合约里有多个同名 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 真正的威力在于:它让你写”接受任何形状匹配的合约”的代码。看下面:

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)就是一堆纯函数,不能有自己的状态变量,但所有员工都能从里面拿工具用。

contractlibrary
能有 state?
能被部署?视情况(internal-only library 内联进调用者)
能继承?
using ... for 语法

using SafeMath for uint256 这一行把 library 的函数挂到一个类型上——之后所有 uint256 变量都能直接 .add().sub().mul(),编译器自动转成 SafeMath.add(x, 1)

💡 0.8 之后整数溢出自动 revert——SafeMath 在新代码里基本退役了,但库这个机制本身还在大量用于工具函数(StringsAddressSignedSafeMath 等)。

🧪 顺便讲一个 CS 概念 · 纯函数 (pure function)

注意 SafeMath.add 上面那个修饰词:internal pure。这个 pure 不是 Solidity 自己发明的——它来自一个叫函数式编程的编程哲学。一个函数被称为”纯的”(pure),要同时满足两件事:

  1. 输入定输出 · 像数学课本里的 f(x) = x + 1,给 3 必返 4,永远。不会因为”现在是几点”、“链上谁有多少钱”、“今天天气怎样”而变。
  2. 不碰外面 · 不读 state、不写 state、不发 event、不调别的合约。它就是一个”输入进去→输出出来”的密封小盒子

为什么这件事和 library 强相关?因为 library 自己没有 storage——它根本没东西可碰——所以它的函数天然就被推向 pureSafeMath.add(3, 5) 永远是 8,无论你在哪个合约里调、无论区块号是多少、无论链上正在发生什么交易。

纯函数为什么值得追求

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 结果跟你想象的对不对):

操作期望看到
1compile + deploy(你 Alice 就是 owner)banner 显示合约地址
2mint(self, 1000) —— 给自己铸 1000 MTKoutput: Transfer(0x0, self, 1000) event · 调 balanceOf(self) → inline = 1000
3transfer(Bob, 200) —— 直接给 Bob 转 200output: Transfer(self, Bob, 200) · balanceOf(self) = 800 · balanceOf(Bob) = 200
4approve(Charlie, 100) —— 授权 Charlie 最多花你 100output: Approval event · allowance(self, Charlie) = 100

第 4 步只是签字,钱没动。Charlie 现在有权从你这里 transferFrom 最多 100——但实际拉钱是 B.3 才会演示的事。

🔌 顺手把 IERC20 拿出来 · 给你刚写的 MyToken 套一个 interface

你刚才在 MyToken 里写了 6 个对外的能力 + 2 个 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
关键字contractinterface
内部有 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 都会换人

🔍 合约的执行环境 · 代码 + 存储 + 消息

退一步先讲个普通程序的事。任何一段代码在执行时,都需要两件东西:

  1. 代码 —— 这次执行的指令在哪里
  2. 存储 —— 这次执行读写的数据在哪里

C / Python / Java 写一个函数时,“代码”是 .text 段、“存储”是堆栈和堆。Solidity 也一样:一个合约 = 一份代码(部署时定死、不可改)+ 一份存储(key-value 表 · 链上持久化)。这两件东西绑死——你说”我调 Machine 合约”,意思是同时拿到了 Machine 的代码和 Machine 的存储。

区块链上的调用比普通函数调用多一件事:它是一封消息。所以合约的执行上下文 = 代码 + 存储 + 消息

  1. 消息 —— 区块链特有 · 内含 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 是谁

phase 0 / 3
AliceEOA

0xA1ce…

外部账户 (EOA) · 无代码

wallet · 链上持久化

MTK:1000

Alice 即将寄出第一封消息 · 点 next ↗

当前执行上下文 = 代码 + 存储 + 消息

代码 · code

这次执行用谁的指令

(空)

存储 · storage

读写谁的 key-value 表

(空)

消息 · message

区块链特有 · 上一跳那封信

msg.sender
msg.value— wei
address(this)

初始 · Alice 钱包里有 1000 MTK · Machine 刚 deploy 完、还没 MTK · 点 next → 让 Alice 发起调用。

// 记住这张图:每一跳 = 一封信 · 信封上的 from 就是接收方看到的 msg.sender。 普通 call 三件全换(代码 / 存储 / 消息)。C.3 的 delegatecall例外——只换代码、存储和消息保留

——重点看 phase 2 那一跳:Machine 内部调 token.transfer(...) 时,面板上三件套同时切换

回看你 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 的物理规则逼出来的:

  1. Alice → token.approve(Machine, 100) —— Alice 直接打到 MyToken,msg.sender = Alice,所以 allowance[Alice][Machine] = 100 能正确记录”是 Alice 授权的”
  2. 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 切换调用目标:

切到操作关键观察
1deploy: MyToken点 ▶ compile → 点 ⬢ deploy(无构造参数)顶部出现 banner · 这是 MyToken 的地址 · 设为 A · 用 ◆ copy 把它复制到剪贴板
2deployed ◉ MyTokenmint(self, 1000) —— 给自己铸 1000 MTK 当购物预算Transfer(0x0, self, 1000) event · 调 balanceOf(self)= 1000
3deploy: VendingMachineToken切下拉到 VendingMachineToken · 出现一个 address _token: 输入框 · 把剪贴板里的 A 粘贴进去 · 点 ⬢ deploybanner 切到 VendingMachineToken 地址 · 设为 B · 中间的 deployed 栏里出现两个 chip:○ MyToken (A) · ◉ VendingMachineToken (B)
4deployed ◉ 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) · 点 ▶ compilecompile ok · 但 EVM 上旧 B 还在 · 还没新版
6deploy: VendingMachineToken_token 输入框里 A 还在(保留草稿)· 点 ⬢ deploy 第二次新的 VendingMachineToken 地址 B’ · 中间 deployed 栏现在有 3 个 chip:○ MyToken · ○ VendingMachineToken(旧 B) · ◉ VendingMachineToken(新 B')
7deployed ○ MyToken切回 MyToken · 调 approve(B', 100) —— 要授权新的 B’,不是旧的 B(◆ copy B’ 的地址过来用)Approval event · allowance(self, B') = 100
8deployed ◉ VendingMachineToken (B')切回 B’ · 再调 buySoda()SodaSold(self, 100) · sodaCount() = 99 · bought(self) = 1 · vaultBalance() = 100

第 4 步那个 revert 是这一节最重要的”AHA 时刻”——不是”忘了 approve”那种表面错,是 msg.sender 在跨合约时换人这条物理规则把整个 ERC-20 收款的形状定死了

  1. transfer 用 msg.sender 当源头 → 跨合约后源头就变成中间合约自己 → 没法用来”代用户付款”
  2. 所以必须有 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。修复有两种思路:

  1. “先归零再设值”approve(bob, 0)approve(bob, 50)。但归零的瞬间 Bob 还有机会抢跑——理论上仍然不安全
  2. 原子操作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 的”性格”彻底改了:

下面的 widget 把两份 storage 并排——点 next → 看 mint 和 transfer 时谁动了哪一格、再次确认”形状一样、主键反过来”:

◆ ERC-20 vs ERC-721 · storage 布局对比

0

初始

1

mint · 铸造

2

transfer · 转账

▸ phase 0 ·初始

两边 storage 都是空 · 还没有任何 token

ERC-20 · MyToken · 主键 = 地址

mapping(address => uint256) public balanceOf;
addressbalance

(还没 mint · mapping 是空的)

totalSupply: 0

ERC-721 · MyNFT · 主键 = tokenId

mapping(uint256 => address) public owners;
tokenIdowner

(还没 mint)

mapping(address => uint256) public balances;
addresscount

(同上)

两边 storage 都还是空 mapping。注意主键的类型差异——左边 key 是 address,右边 key 是 uint256(tokenId)。 这就是 fungible vs non-fungible 在 storage 层的物理体现。

// 形状几乎一样、只是主键反过来——address→uint 是"每人一个余额",uint→address 是"每件物品一个主人"。 理解这个对称,所有 token 标准就读通了。

🔌 一个真案例 · 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、还是别的—— 只要符合形状就能调 transferFromtransfer

❸ 点选 tokenA + tokenB · 看哪些能插进去

tokenA =USDC · USD Coin
tokenB =DAI · Dai

结果 · `new SimpleSwap(USDC, DAI)`

✓ SimpleSwap(USDC, DAI) deploy 成功

两个槽都是 IERC20 形状 · swap 能正常 transferFrom + transfer · 任何人可以发 tx 用 USDC ↔ DAI 互换 · 代码一字未改。

// 这就是 DeFi 乐高的物理基础:所有 ERC-20 都共享 IERC20 这个形状, 所以 Uniswap 的一份代码服务上千种 token 对——而且未来还没发行的 token 只要遵守 IERC20,今天的 Uniswap 代码自动支持它。 这就是 interface 的真正威力——对未来的"形状契约"

模块 C · 合约 ↔ 合约

C.1 · 合约调合约 · 两大主线 · calldelegatecall

合约 A 想触发合约 B 上的某段代码,实质上只有两种调用方式

  1. call —— 普通调用 · “派人去 B 公司办事” · 用 B 的代码 + B 的存储 + 新构造的消息
  2. delegatecall —— 委托调用 · “借 B 的脑子在自己家里跑” · 用 B 的代码、但留在 A 的存储 + A 的消息

两者的差别只在执行上下文里”代码”和”存储”两个槽的归属——回看 B.3 的 🔍 执行上下文 那张图。下面的 widget 把 A 和 B 摆出来,让你切两种模式、点 ▶ A.poke()亲眼看 x += 1 这一行最后写到了谁的 storage

◆ A 调 B · call vs delegatecall · 三件套都看

AliceEOA

0xA1ce…

poke()
Contract Acontract

0xAaaa…

code

function poke() {
  b.call(...);
}

storage

x:100

✉ message

foo()

A.call(B)

Contract Bcontract

0xBbbb…

code · ← 这次跑的

function foo() {
  x += 1;
}

storage · ← x += 1 写的是它

x:200

⚡ B.foo() 跑的时候 · 执行上下文长这样

代码 · code

谁的指令在跑

B

存储 · storage

读写谁的状态

B

msg.sender

上一跳的发起方

0xAaaa…

address(this)

当前合约自己

0xBbbb…

call 模式 · 四件全是 B 的(除了 sender 是 A)· 普通的"派人去 B 公司办事"—— 在 B 的办公室、用 B 的文件柜,对外说"我是 B(address(this) = B)"。

// 一句话:call = 整体跳进 B(用 B 的代码 + B 的存储 + 新消息)· delegatecall = 只借 B 的代码(其他全留在 A · 连 msg.sender 都沿用 A 的上一跳)。 这条规则后面 C.3 storage collision、D.2 proxy 升级都建立在它之上。

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.sendertx.origin——搞混任一个都可能直接出安全事故。下面这个 widget 分两步教:

  1. 第 1 步 · 地址名册 —— 看清楚链上各个 actor 都是谁(Alice + 3 份合约 A/B/C),讲清楚 address(this) 不是”调用者”也不是”原始用户”,它只跟当前正在跑的代码走。
  2. 第 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) 怎么跟着代码走。

AliceEOA

0xA1ce…

A · Vaultcontract

0xAaaa…

B · Strategycontract

0xBbbb…

C · Tokencontract

0xCccc…

phase 0 / 3

当前 address(this) 是谁

address(this)

(没在跑 · 点 next →)

= 当前正在跑的代码所属合约

// 一图记牢三个代词的"换人节奏": address(this)(每跳换)· msg.sender(每跳换 · "上一跳")· tx.origin(永不变 · 最初的 EOA)。 权限检查永远用 msg.sender。

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 的存储布局怎样改变结局

AliceEOA

0xA1ce…

poke()
Contract Acontract

0xAaaa… · (Proxy · 拥有 state)

code

function poke() {
  b.delegatecall(
    abi.encodeWith
      Signature(
        "setX(uint256)",
        42
      )
  );
}

storage · ← 写到的是这里

slot 0:uint256 counter

= 100

slot 1:address owner

= 0xOwn…r

✉ message

borrow setX

A.delegatecall(B)

Contract Bcontract

0xBbbb… · (Logic · 提供代码)

code · ← 这次跑的

// 这是 B 的代码
function setX(uint256 newX) {
  x = newX;  // 写 slot 0
}

storage

slot 0:uint256 x

= 999

slot 1:address signer

= 0xLogic…

⚡ B.setX(42) 跑的时候 · 执行上下文(delegatecall · 借代码不换家)

代码 · code

跑的是 B.setX

B 的

存储 · storage

写的是 A 的 slot 0

A 的

msg.sender

沿用 A 的上一跳

Alice

address(this)

不换 · 仍是 A

A

// delegatecall 看的不是变量名、是物理 slot 编号。Proxy 升级模式有铁律: 新版 Logic 的 storage layout 必须和 Proxy 完全对齐、新增字段只能加在末尾。 2017 年 Parity Multisig 永久冻结 50 万 ETH 就是栽在这条规则上——L5 会专门拆。

这就是 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

AliceEOA

0xA1ce…

外部账户
无代码
无 storage

Contract Acontract

0xAaaa… · (Proxy · 拥有 state)

code

function execute() {
  b.call(
    "doSomething()"
  );
}

storage

value:100
Contract Bcontract

0xBbbb… · (Logic · 提供代码)

code

function doSomething() {
  c.call(
    "logEvent()"
  );
}

storage

counter:0
Contract Ccontract

0xCccc… · (被调 · 只看 msg.sender)

code

function logEvent() {
  emit Log(
    msg.sender
  );
}

storage

lastCaller:

phase 0 / 3

⚡ 当前帧 · 执行上下文(空 · 还没开始

代码 · code

谁的指令在跑

存储 · storage

读写谁的状态

msg.sender

上一跳

address(this)

当前合约

初始 · Alice 准备调 A.execute() · 点 next → 走第一步。

// 铁律:普通 call 时 · 调用方的 address(this) 就是被调方看到的 msg.sender。 delegatecall 不换 address(this) · 所以从一个 delegatecall 帧里发出去的普通 call · msg.sender 是 Proxy(A)· 不是 Logic(B)

为什么 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

0

初始

1

部署

2

用户调 bump

3

admin 升级

4

用户再调 bump

▸ phase 0 · 初始

链上还没东西 · 点 next ▶ 开始部署

CallerEOA

等下一步

Proxycontract

· 用户唯一入口

code

fallback() {
  impl.delegatecall(
    msg.data
  );
}

storage

impl:
admin:
counter:
Logic v1contract

· 老版逻辑

code

function bump()
  external {
  counter += 1;
}
Logic v2contract

· 新版逻辑

code

function bump()
  external {
  counter += 2;
}

初始 · 三个合约还没部署 · admin 已经准备好 Proxy + Logic v1 + Logic v2 的字节码 · 点 next → 开始部署。

// 记住这张图:Proxy 是房子 · Logic 是说明书。 家具(counter / 所有 state)永远不动 · 说明书(Logic 代码)可以换。 升级 = admin 一笔 tx 改 Proxy 的 impl 指针 · 用户感受不到 · 这也意味着 admin 私钥被盗 = 协议被换。 D.2 接着讲谁有权 admin。

主流标准:UUPSTransparent 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 个私钥

点 "触发攻击" 看攻击者拿到一个签名者私钥后能搞多大破坏

// 总结:合约能做什么 = 你愿意把私钥放在哪里。 单签 = 单点失效 多签 = 攻击者要同时拿下 M 个独立人。 从原型到主网的演进路径几乎都是 Ownable → AccessControl → Safe-as-owner

模块 E · 实战

E.1 · 在 Playground 里串一个 Vault + Staker

目标 · 写两份合约:

  1. StakeToken · 一个简单的 ERC-20(继承 OpenZeppelin),代表”质押凭证”
  2. Staker · 一个 Vault 合约——接收用户存入的 StakeToken、按时间发放奖励、允许 withdraw

需要用到的乐高:

// 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 块乐高 ✓ · 但还没人审过

读到这里,你应该能:

6 块乐高拼齐了 · 你已经能写一个 DeFi 协议雏形。

但⋯⋯你写的合约有没有被审过

L3 末尾我们提到 TipJar 的 withdraw 那行有安全隐患——这一课的 Staker 里同样埋着至少三个攻击面:

  1. withdraw 里如果 token 是 ERC-777(带回调),可能被重入攻击
  2. stakedAmount[msg.sender] = 0 写在 transfer 之后还是之前,决定了你能不能被掏空
  3. 如果 admin 是单签的 EOA,私钥被盗 = 协议被盗

L5 我们专门讲这些。 不会写代码不可怕——可怕的是写出了能跑、能上线、能管钱的代码,却不知道哪里能被人攻穿

思考题 · 翻回去看 Staker 的 withdraw 函数。如果攻击者部署一个恶意 ERC-20,让 transfer 里反过来调 Staker.withdraw(),会发生什么?