覆盖第 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.sender、address(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 讲