覆盖第 21–25 讲


题 1 字节码推演

(a) 手工执行 6002 6003 02 6000 52 6020 6000 f3,写出每一步的栈状态和最终返回值。

(b) MSTORE 的参数顺序是什么?如果写反会怎样?

(c) 估算这段代码消耗的 Gas。

解答

(a)

PC  指令           操作                        栈(栈顶在右)
─────────────────────────────────────────────────────────────
00  PUSH1 0x02    压入 2                      [2]
02  PUSH1 0x03    压入 3                      [2, 3]
04  MUL           弹出 3、2,压入 6            [6]
05  PUSH1 0x00    压入 0(内存偏移)            [6, 0]
07  MSTORE        ⭐ 弹偏移 0、弹值 6           []
                  memory[0..31] = 6
08  PUSH1 0x20    压入 32(返回长度)           [32]
0a  PUSH1 0x00    压入 0(返回偏移)            [32, 0]
0c  RETURN        返回 memory[0..31]           —
─────────────────────────────────────────────────────────────
返回值:0x0000...0006(32 字节大端表示的 6)

(b)MSTORE 先弹出偏移,再弹出值。 所以压栈顺序必须是先压值、后压偏移

⚠️ 写反的话(先压偏移、后压值):
   本例会变成 "把 0 写到内存地址 6"
   ⟹ memory[6..37] = 0,而 memory[0..31] 仍是 0
   ⟹ RETURN 返回全零 —— 不报错,只是【静默错误】

“参数逆序"是栈机编程最常见的 bug 来源,且它通常不会崩溃,只会给出错误结果。

(c)

PUSH1 × 5     = 5 × 3  = 15
MUL           = 3
MSTORE        = 3
内存扩展(1 字,从 0 到 32 字节)= 3×1 + 1²/512 ≈ 3
RETURN        = 0
─────────────────────────────
合计 ≈ ⭐ 24 Gas

题 2 256 位与 ZK 电路

(a) 256 位字长的两个设计理由是什么?

(b) 为什么它对 ZK 证明极不友好?位运算为什么比算术更糟?

(c) Type 2 zkEVM 为什么要换掉状态树的哈希函数?

解答

(a)

① ⭐ 匹配密码学原语:keccak256 输出 256 位,地址是它的后 160 位
   ⟹ 哈希和地址运算不需要拆分
② 金额不溢出:1 ETH = 10¹⁸ wei
   64 位最大约 1.8 × 10¹⁹,只够表示 18 个 ETH

(b)

⭐ ZK 电路在【有限域】上工作,域大小通常是一个 ~64 或 ~254 位的素数。

而 EVM 的 256 位运算【不是域运算】:
   它是"带进位的整数运算 + 取模 2²⁵⁶"。
⟹ 在电路里模拟一次 256 位加法,必须:
   ① 拆成多个小段
   ② 显式处理段间进位
   ③ 对每段做范围检查(证明它确实小于 2^k)
⟹ 一条 EVM 指令可能对应成千上万个约束

⚠️ 位运算更糟:

算术电路的原生操作只有【加法和乘法】。
AND / OR / XOR / SHR 在代数上无法直接表达,
⟹ ⭐ 必须把每个 256 位数【逐位分解】成 256 个 0/1 变量,
   并为每一位加一个"是布尔值"的约束
⟹ 一次 256 位异或就是 256+ 个约束起步

一句话:EVM 是为"链上执行便宜"设计的,不是为"被证明"设计的。

(c) 因为 keccak 是最大的成本项。

keccak 为 CPU 优化,大量位运算和置换
⟹ 在算术电路里一次 keccak 可能上万个约束

⚠️ 而致命的是:MPT 到处都是 keccak(第 12 讲)——
   每次状态读写都要验证一条 Merkle 路径,每层一次 keccak。
⟹ 光是"证明状态访问是合法的",就吃掉了大部分证明成本。

⟹ Type 2 改用 Poseidon 这类【为算术电路设计】的哈希,
   约束数少两个数量级。
代价:依赖 keccak 状态树的现有工具需要适配。

题 3 Gas 定价与上海攻击

(a) 2016 年上海攻击的机制是什么?攻击者花的 Gas 与节点付出的真实成本比例大约是多少?

(b) 从中得出的定价原则是什么?

(c) 冷访问 2100 而热访问 100,差 21 倍。如果定成 5 倍会有什么风险?

解答

(a)

⚠️ EXTCODESIZE 当时只收 20 Gas,
   但它需要从磁盘读取一个账户——一次【随机 I/O】(第 12 讲)。

攻击:写一个循环,对上千个【随机地址】调用 EXTCODESIZE
⟹ 每 20 Gas 换来一次随机磁盘读
⟹ 出块时间从 15 秒涨到几分钟,部分节点直接跟不上

比例估算:一次随机 I/O 在当时的机械/早期 SSD 上是毫秒级,而 20 Gas 对应的"应有成本"是微秒级。⟹ 相差约三个数量级。

(b)

Gas 价格必须反映「对最慢的那个节点」的真实成本,而不是"对一台高配机器的平均成本”。

⚠️ 而"真实成本"会随硬件、状态大小、实现方式变化:

⟹ ⭐ Gas 定价不是一次性设定的常数,
   而是一个需要【持续维护】的参数集合。

EIP-150、EIP-2929、EIP-3529 都在这条线上。

(c)

⚠️ 如果定成 5 倍(冷 500 / 热 100):
   攻击者仍然可以用相对便宜的价格触发大量随机 I/O。

判断标准是:这个比例是否覆盖了【最慢节点】上
   "一次随机磁盘读" 与 "一次内存读" 的真实差距。

   NVMe 随机读延迟 ≈ 100 μs 量级
   内存读 ≈ 100 ns 量级
   ⟹ 真实差距在 1000 倍量级
   ⟹ 21 倍其实已经是【偏保守】的定价(考虑了缓存命中率)
   ⟹ 5 倍会明显低估,重新打开 DoS 空间

题 4 EIP-1559 计算

(a) 基础费从 10 gwei 开始,连续 5 个满块后是多少?连续 5 个空块后呢?

(b) 从 10 gwei 涨到 3 倍需要多少个满块?折合多长时间?

(c) “EIP-1559 让 Gas 变便宜了”——用供需分析说明这句话为什么不成立。

解答

(a)

满块:gasUsed = 2 × target ⟹ 每块 ×(1 + 1/8) = ×1.125
   10 × 1.125⁵ = 10 × 1.8020 ≈ ⭐ 18.02 gwei

空块:gasUsed = 0 ⟹ 每块 ×(1 − 1/8) = ×0.875
   10 × 0.875⁵ = 10 × 0.5129 ≈ 5.13 gwei

注意不对称:涨得比跌得快(1.125 > 1/0.875 = 1.143 的倒数关系并不对称),这是有意的——拥堵时要迅速定价出去。

(b)

1.125ⁿ ≥ 3
n ≥ ln 3 / ln 1.125 = 1.0986 / 0.1178 ≈ 9.32

⟹ ⭐ 10 个满块(1.125¹⁰ ≈ 3.25)
⟹ 10 × 12 秒 = 2 分钟

突发需求会在两分钟内被定价出去,而不是让交易无限期堆积。

(c)

费用由供需决定:
   供给 = 区块空间 —— ⚠️ EIP-1559 没有增加它
       (区块上限从固定的 15M 变成"目标 15M / 上限 30M",
        但【长期平均】仍然锚定在 15M)
   需求 = 用户想做多少交易 —— EIP-1559 没有减少它

⟹ 均价不变是必然的。

它真正改善的是:

✅ 费用可预测(基础费公开,变化有 ±12.5% 的上界)
✅ 大幅减少超付(不再需要盲目高出价)
✅ ⭐ 消除了验证者操纵拥堵自肥的动机(基础费被销毁)
✅ 区块弹性:上限是目标两倍,突发需求有缓冲

一句话:它解决的是"费用估计"问题,不是"费用高"问题。后者只能靠扩容。


题 5 存储布局优化

一个合约声明如下:

uint64  a;
uint256 b;
address c;
uint64  d;
uint96  e;

(a) 写出槽分配。

(b) 重新排序使占用最少,算出省了多少 Gas(假设首次写入全部字段)。

(c) 什么情况下打包反而更贵?

解答

(a) 字节数:a=8, b=32, c=20, d=8, e=12。按声明顺序:

slot 0:a (偏移 0, 8 字节)        ⚠️ 剩 24 字节,但 b 需要 32,装不下
slot 1:b (偏移 0, 32 字节)       满
slot 2:c (偏移 0, 20 字节)
        d (偏移 20, 8 字节)       已用 28,e 需要 12,装不下
slot 3:e (偏移 0, 12 字节)

⟹ 共 4 个槽

(b) 总字节 8+32+20+8+12 = 80 ⟹ 理论下限 ⌈80/32⌉ = 3 槽。

uint256 b;   // slot 0(独占,32 字节)
address c;   // slot 1,偏移 0  (20 字节)
uint96  e;   // slot 1,偏移 20 (12 字节)⭐ 20+12=32,正好塞满
uint64  a;   // slot 2,偏移 0
uint64  d;   // slot 2,偏移 8
⟹ ⭐ 3 个槽,省下 1 个

Gas 收益:

首次写入(零 → 非零):20000 Gas/槽
⟹ ⭐ 省 20000 Gas

后续每次"全字段更新":
   4 槽 → 3 槽,省一次 SSTORE ≈ 2900 Gas
   若是冷访问,还额外省 2100 Gas 的冷读费用

(c) ⚠️ 当字段总是被【分别】访问时。

打包的收益:多个字段共用一次 SLOAD/SSTORE
打包的成本:⭐ 读写单个字段需要【位移 + 掩码】运算,
           且写入时必须"读出整槽 → 改一部分 → 写回"

例:address owner(几乎不变)+ uint96 count(频繁自增)打包在一起
   ⟹ 每次改 count 都要:SLOAD 整槽 → 掩码 → 移位 → SSTORE
   ⟹ 相比 count 独占一个槽,多了若干条指令,
      而并没有省下任何一次 SSTORE

判据:字段是否【经常被同时读写】。是则打包,否则分开。


题 6 mapping 槽推导

(a) 一个 mapping(address => uint256) 声明在 slot 3。写出计算 m[0xAB…CD] 所在槽的完整步骤。

(b) mapping(address => mapping(address => uint256)) allowance 在 slot 5,写出 allowance[a][b] 的槽。

(c) 为什么 mapping 的 slot 本身留空?为什么无法遍历 mapping?

解答

(a)

① 把 key 左填充到 32 字节:
   0x000000000000000000000000AB…CD          (地址 20 字节 → 前面补 12 字节零)

② 把 slot 号左填充到 32 字节:
   0x0000…0003

③ 拼接成 64 字节,取 keccak256:
   ⭐ 槽 = keccak256( pad32(key) ‖ pad32(3) )

⚠️ 最常见的错误是忘记左填充——直接用 20 字节的地址拼接会得到完全不同的槽。

(b)

⭐ 内层先算:inner = keccak256( pad32(a) ‖ pad32(5) )
   外层再算:槽   = keccak256( pad32(b) ‖ inner )

⚠️ 注意顺序:先用外层键 a,再用内层键 b。搞反会得到错误的槽。

(c)

① 为什么留空:
   ⭐ 键空间是 2²⁵⁶,不可能预分配任何连续区间。
   所以 mapping 不像数组那样"在 slot 里存长度"——
   它根本没有"长度"这个概念。

② 为什么无法遍历:
   值被散列到整个 2²⁵⁶ 空间的各个角落,
   而【没有任何地方记录"哪些键被用过"】。
   ⟹ 要遍历就得扫描 2²⁵⁶ 个槽 —— 不可能

要能遍历,必须自己额外维护一个键的数组,代价是每次新增键多一次 SSTORE(20000 Gas)。


题 7 delegatecall 与存储冲突

(a) 给出代理与实现的变量声明,说明哪一次写入会把合约变砖。

(b) EIP-1967 为什么要减 1?

(c) A 调用 B,B 用 delegatecall 调用 C。在 C 的代码里,msg.senderaddress(this) 分别是谁?读写的是谁的存储?

解答

(a)

contract Proxy {
    address implementation;   // ⚠️ slot 0
    fallback() external { /* delegatecall 到 implementation */ }
}

contract Logic {
    address owner;            // 也是 slot 0
    function setOwner(address o) external { owner = o; }
}
用户调用 Proxy 的 setOwner(X)
   ⟹ delegatecall 到 Logic
   ⟹ Logic 的代码执行 "写 slot 0 = X"(它以为在写自己的 owner)
   ⟹ ⭐ 但 delegatecall 用的是【Proxy 的存储】
   ⟹ Proxy 的 implementation 被改成了 X

⟹ 下次调用时,Proxy 会 delegatecall 到地址 X
⟹ 若 X 不是合约,调用返回成功但什么也不做;
   数据全部锁死在 Proxy 里,永远取不出来。

(b)

槽号 = keccak256("eip1967.proxy.implementation") − 1

① 用哈希做槽号:⭐ Solidity 的顺序分配永远从 0、1、2… 开始,
   撞上一个 2²⁵⁶ 空间里的随机位置的概率可忽略

② 减 1 的作用:让这个槽号【不是任何已知字符串的 keccak 输出】。
   如果它恰好等于 keccak256(x),
      那么一个 mapping(其槽 = keccak256(key ‖ slot))
      或动态数组(其数据起点 = keccak256(slot))
      就可能通过精心构造的键映射到这里。
   ⟹ 减 1 后,要撞上它需要先找到一个哈希原像 —— 不可行

这是一个典型的"防御性偏移"技巧。

(c)

B 用 delegatecall 调用 C:
   ⭐ msg.sender  = A          (不是 B!保持了 B 收到的值)
   address(this) = B
   读写的存储  = B 的存储
   msg.value   = B 收到的 value(不能重新指定)

⚠️ msg.sender 是 A 而不是 B,这一点是很多权限漏洞的根源:一个函数以为自己在检查"直接调用者",实际检查的是更外层的人。


题 8 63/64 规则

(a) 调用深度攻击的完整步骤是什么?

(b) 证明 63/64 规则让它失效:要真的达到 1024 层,需要多少初始 Gas?

(c) 这与区块 Gas 上限相比如何?

解答

(a)

① 攻击者先递归调用自己 1023 层
② 在第 1023 层调用受害合约
③ ⭐ 受害合约内部的任何子调用(比如转账)必然失败——
   因为已到 1024 层上限
④ 若受害合约没检查返回值,就会误以为转账成功,
   继续执行后续逻辑(比如把余额清零)

(b)

每次子调用最多转交剩余 Gas 的 63/64。
n 层之后剩余比例 = (63/64)ⁿ

(63/64)¹⁰²⁴ ≈ 9.92 × 10⁻⁸

⭐ 要在第 1024 层还剩下约 700 Gas(发起一次 CALL 的最低成本):
   初始 Gas ≥ 700 / (9.92 × 10⁻⁸) ≈ 7.06 × 10⁹

(c)

区块 Gas 上限 = 3 × 10⁷

7.06 × 10⁹ / 3 × 10⁷ ≈ ⭐ 235 倍

所需 Gas 是整个区块上限的 235 倍——深度攻击在物理上不可能达成。Gas 会先于深度耗尽。


题 9 账户抽象

(a) EOA 的四个硬限制是什么?哪一个对新用户上手影响最大?

(b) 画出 EIP-4337 的调用链,标出每一步谁在付 Gas。

(c) 4337 和 7702 的定位差别是什么?

解答

(a)

① 只能用 secp256k1 的 ECDSA
② ⚠️ 必须用 ETH 付 Gas
③ 一笔交易只能有一个 to
④ 私钥丢失 = 资产永久丢失

第 ② 条对新用户影响最大:一个刚拿到 USDC 的人,因为没有 ETH 而完全无法动用自己的资产——包括无法把 USDC 换成 ETH。这是一个自锁死的状态。

(b)

用户 ──(签名 UserOperation,⭐ 不是交易)──▶ UserOp mempool
                                              │
                                     Bundler 打包
                                              │
                    Bundler 发出一笔【真实交易】,由他付 Gas
                                              ▼
                                       EntryPoint 合约
                                          │      │
              ① validateUserOp ───────────┘      └──── ② 执行调用
                        ▼                                  ▼
                   钱包合约                            目标合约
              (自定义验证逻辑)

Gas 的最终承担者:
   ① 若无 Paymaster:EntryPoint 从【钱包合约的预存款】中扣除,补偿 Bundler
   ② 若有 Paymaster:由 Paymaster 承担
      (它可以向用户收 USDC,也可以完全免费——比如项目方补贴)

⚠️ Bundler 承担了"先垫付、后报销"的风险——所以它会先模拟,确认能拿回 Gas 才打包。

(c)

4337:⭐ 完整的账户抽象
      但需要迁移到一个【新的合约钱包地址】

7702:让【存量 EOA】临时"贴上"合约代码
      地址不变、资产不迁移,改动小得多
      但能力受限于"临时授权"这个模型

⟹ 二者互补而非替代:
   7702 让几亿存量 EOA 立刻拿到大部分好处,
   4337 提供完整的、长期的账户抽象生态。

题 10 并行执行

(a) 为什么 Block-STM 的验证必须按原始顺序?乱序会怎样?

(b) 构造一个会大量重跑的场景。

(c) 为什么"并行结果必须与串行完全一致"是硬要求而不是优化目标?

解答

(a)

⭐ Block-STM 的语义目标是:结果等价于【按原始顺序串行执行】。

验证一笔交易 i 的问题是:
   "我当初读到的值,是不是【所有排在我前面的交易】写完之后的值?"

若乱序验证:
   交易 5 通过验证时,交易 3 可能还没重跑完,
   ⟹ 交易 5 读到的可能仍是过时值,而它被判定为"有效"
   ⟹ 最终结果与串行不一致

按序验证保证了一个不变式:当验证到 i 时,前 i−1 笔已经全部定稿。

(b)

⭐ 场景:一个热门 NFT 开始铸造,区块里 200 笔交易全部调用同一个合约,
   且每笔都会写 totalSupply 这一个存储槽。

⟹ 所有 200 笔的写集都包含同一个键
⟹ 乐观并行执行后,除了第 1 笔,其余 199 笔的读集全部失效
⟹ 逐一重跑,退化成串行
⟹ 而且还白白付出了第一轮并行的开销 ——【比纯串行更慢】

结论:并行化的收益完全取决于交易之间的冲突率,而不是核心数。

(c)

⭐ 因为状态根是共识的一部分(第 9、12 讲)。

不同节点可能:
   ① 用不同的实现(Geth / Nethermind / Erigon…)
   ② 用不同的核心数
   ③ 用不同的调度顺序

若并行结果与串行不同,且依赖调度细节,
   ⟹ 不同节点会算出【不同的状态根】
   ⟹ 区块互相拒绝 ⟹ 共识立刻崩溃

所以并行在这里是【纯粹的实现优化】,它必须对协议语义完全透明——协议规范只描述串行语义,节点怎么算是自己的事,但结果必须一模一样。


相关第 21–25 讲