第二层第二篇。06 说"账本一行装 { 余额, nonce, code, storage }", 但那段 code 怎么跑、谁付计算费、按什么单价、失败了怎么算钱——这一篇答。 直接落到项目里反复出现的具体数字:via_ir = true 为什么开、 gasUsed = 359816 / 487252 / 897176 这些值各自来自哪、 maxFeePerGas: 50 gwei / maxPriorityFeePerGas: 2 gwei(demo-flashbots.ts) 是 EIP-1559 哪两件事、require revert 为什么"还要付钱"。 沿用 01–06 的村庄画面。结构见 README.md §1。


0. 一句话本质

EVM = 一台所有节点都跑同一份代码、按同一规则得出同一答案的世界级计算器。 Gas = 每条 EVM 指令的"按动一次"价格——既给计算定价、又防止有人写死循环把全网卡死。 你为 EVM 做过的工作付钱(gasUsed × 单价),无论结果是成功落入账本还是 revert 整个回滚。


1. 怎么产生的:它解决什么问题

06 留了几个 EVM 层面才能答的具体疑问:

  1. 合约的"那段 code"由谁跑、按什么规则跑?为什么全网节点都能算出同一答案? ⇒ 一台所有人都同步实现的虚拟机:EVM。
  2. 公共计算机怎么不被滥用? 写个 while(true) 就能把全网卡死——必须给计算定价 ⇒ Gas。
  3. 付多少?谁定价? EIP-1559 之后是"协议自动调 base fee + 你出小费 tip"的拍卖市场。
  4. require revert 之后我的钱白付了? 是的。EVM 的"原子性"承诺是状态全回滚,不是"钱全回滚"——已执行的工作仍要付。
  5. forge build 报 "Stack too deep" 是什么?为什么开 via_ir = true 就好了? EVM 栈深 1024 但可寻址窗口只有 16,编译器变量塞不下。via_ir 换条 Yul IR 路径优化分配。
  6. gasUsed = 487252 这个具体数从哪来? 每条 EVM 指令有固定基础成本(SSTORE ≫ SLOAD ≫ ADD),一笔 tx 是它们的累加。

→ 这一篇答全部。


2. 它本质在做的"那一件事"

把"以太坊"从"账本"提升为"世界状态机 + 计费虚拟机":每笔交易都是一段被 EVM 执行的程序,全网节点独立跑同一份、得同一答案;每一步操作都明码标价,你预付一个上限,按实际用量结算,超限或失败照样付计算费但状态全回滚。

forge test --fork 之所以可信(03 §8)—— 不仅因为 state root,还因为所有节点的 EVM 实现都是确定性的同一份;我们本地 Anvil 跑 EVM、主网节点跑 EVM,对同样输入产出同样输出,整个 12/12 wei-exact 测试群依赖这一条。


3. 类比:村庄添了"公共计算器"

01–06 的村庄已经有了识字村民(节点)和公共售货机(合约账户)。 07 加最后一台关键设施:

┌── 公共计算器 = EVM ─────────────────────────────────────┐
│ 全村每户家里都有一台一模一样型号的计算器                  │
│ 任何人提交一张"按键脚本"(交易调合约)→ 每台计算器独立按  │
│ 同样按键 → 一定得到同样答案(确定性)                    │
│                                                          │
│ 计算器有一条铁规:**按一下键收一个筹码(gas)**           │
│ 你提交脚本前要说:"我最多愿意花 N 个筹码、每个出 P 元"     │
│ 计算器按完算:N_used × P_unit = 总价                     │
│ 中途撞墙(require false / 没筹码了)→ **筹码已花的不退**  │
│ 但**对账本的所有改动全部撤销**("原子性"承诺)           │
│                                                          │
│ EIP-1559 后单价变成两部分:                              │
│   - base fee("村规价")每个 slot 按上块拥挤度自动调      │
│   - priority fee("加急小费")由你出,给本块执笔人        │
│   - base fee **烧掉**(销毁通胀,ETH 变通缩资产)         │
└─────────────────────────────────────────────────────────┘

为什么烧 base fee?让 Gas 价格成为协议的内置回收机制,proposer 不能靠主导 base fee 牟利; 加急小费才是 proposer 真正收入——所以排序权归根结底变成"谁愿付更高 tip 的拍卖"。 → MEV 在 §4 链回到 04 PBS:builder 想方设法塞更多高 tip 的交易,自然包括我们的 bundle。


4. 核心关键点(5 条)

  1. EVM = 确定性、计费、堆栈式虚拟机。所有节点跑同一实现 ⇒ 同输入必同输出 ⇒ 状态过渡可被全网独立验证(连回 03 state root 一致性)。
  2. Gas 同时解三个问题
    • 计算定价(不同操作不同成本,SSTORE ≫ SLOAD ≫ ADD)
    • DoS 防护(写死循环?超 gasLimit 自动停)
    • 停机问题工程化(不需要数学上判停,钱花完即停)
  3. EIP-1559 双价制effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
    • base fee 烧(销毁),按上块拥挤度自动调
    • priority fee(tip)给 proposer,由你出价
    • maxFeePerGas 是你愿付的总上限;maxPriorityFeePerGas 是其中愿给 proposer 的部分
  4. 失败 ≠ 免单:revert 把状态全部回滚(这是原子性),但已执行的指令照样付费。 "失败只亏 gas"= 这条;不是"什么都不亏",是"不亏本金,只亏算费"。
  5. Stack vs Memory vs Storage 成本三级跳
    • 栈 / 内存:临时,便宜(内存按字节扩展计费)
    • storage(持久):新 SSTORE 一槽 ≈ 20,000 gas(不为零→零退一点;为零→非零最贵)
    • SLOAD ≈ 2100/100 gas(冷/热)
    • 外部 CALL ≈ 2600 起步 + 内部消耗 ⇒ 我们 12 个测试 gasUsed 三五十万 ~ 九十万的来源就是这套加法

5. 必须先认识的前置名词(小词典)

名词 一句话 类比
EVM Ethereum Virtual Machine,全网节点都跑的字节码解释器 全村同型号计算器
bytecode / opcode 合约编译后的字节码 / EVM 指令集(~140 条) 计算器键盘 / 单个按键
stack(栈) 32 字节宽、最深 1024 的临时操作区;从栈顶 16 槽内寻址 计算器显示屏堆叠区,太深够不到
memory(内存) 单次调用内的临时字节数组;扩展按平方阶计费 草稿纸,越写越贵
storage(存储) 合约持久 32B→32B 库(06 §4.4);最贵 售货机内部账本
calldata tx 携带的只读输入数据;读它较便宜 投币条上写的指令
gasLimit / gasUsed 你愿花的最大筹码 / 实际花了多少 预付额度 / 实际消耗
gasPrice(legacy) / EIP-1559 双价 每筹码一价 / maxFeePerGas + maxPriorityFeePerGas 老单价 / 上限 + 小费
base fee 协议按上块拥挤度自动调的"基础价",销毁 村规价(不入私囊)
priority fee / tip 你给 proposer 的小费,决定优先级 加急费
effectiveGasPrice 实际单价 = min(maxFeePerGas, baseFee + tip) 最终单价
gas refund 把存储清零等有退款机制(Cancun 后大幅削减) 押金部分退还
revert / require / assert 状态全回滚、抛错;assert 还会消尽 gas 撞墙撤销
out of gas (OOG) 跑超 gasLimit → 自动 revert 筹码花完,账本不动
CALL / STATICCALL / DELEGATECALL 普通调用 / 只读调用 / 借壳调(保留原 storage 与 msg.sender) 投币机 A / 只看 / 让 A 用我的内部账本干活
viaIR / Yul Solidity 走 Yul 中间表达再编译,缓解 Stack-too-deep 走另一条编译路
mempool → propagate → include → finalize 待打包 → 全网传播 → 进块 → 经济终局 04 §6 复习
Receipt / Logs / Events 一笔 tx 的执行结果 + 它发出的事件 收据 + 公告栏便条

6. 一笔交易的完整一生(端到端,再放大 01 §6)

01 §6 给的是网络层故事。本节放大到 EVM 执行层——以我们 pnpm demoarb tx 这一笔为例:

─────────────────────────────────────────────────────────────
① 构造
   tx = { from=EOA(0xf39F…), to=Arbitrageur(0xDda8…),
          data=ABI编码( arb([UNI,SUSHI], USDC, 26869e6) ),
          nonce=current, value=0,
          gasLimit=1_500_000,
          maxFeePerGas=50 gwei, maxPriorityFeePerGas=2 gwei,
          chainId=1 }
─────────────────────────────────────────────────────────────
② 签名(02)→ 广播 → mempool(05 §3.2)
─────────────────────────────────────────────────────────────
③ 出块(04 §6 PoS slot)
   该块的 baseFee(比如 22 gwei)已由协议根据上块拥挤度算好
   effectiveGasPrice = min(50, 22 + 2) = 24 gwei
─────────────────────────────────────────────────────────────
④ EVM 执行(核心)
   - 从该 EOA 余额预扣 gasLimit × maxFeePerGas(封顶预付)
   - 加载 to.code(Arbitrageur 字节码),按函数选择器分发到 arb()
   - 把 calldata 解码进栈/内存
   - 执行 EVM 指令流:
       PUSH/POP/DUP/SWAP ……         (cheap,几 gas 一条)
       SLOAD     读自己 storage      (cold 2100 / warm 100)
       SSTORE    写自己 storage      (新写 20k;改写较少;清零退一点)
       CALL      → Aave Pool.flashLoanSimple(…)   嵌套新帧
         └── Aave 内部把 USDC 转给我们 → 调我们 executeOperation
             └── 我们再 CALL Uni V2 pair.swap → 嵌套帧 → ...
       ...
   - 每条指令累计扣 gas;中途任意 require 失败 → revert
       · 状态:本 tx 的全部改动**整体撤销**
       · gas:**已用部分照付**("失败只亏 gas"在此)
   - 全程成功 → 累加得 gasUsed(例如 487,252)
─────────────────────────────────────────────────────────────
⑤ 结算
   actual fee = gasUsed × effectiveGasPrice
              = 487252 × 24e9 wei ≈ 0.0117 ETH
   其中:
     - baseFee 部分(487252 × 22e9)**销毁**
     - tip 部分 (487252 × 2e9)给本块 proposer
   退还 EOA 多预扣的差额
─────────────────────────────────────────────────────────────
⑥ 新 state(06 §4.5)写回 / state root 更新(03 §4.5)/
   Receipt 写入:{ gasUsed, status=1, logs=[…] } →
   Logs(events)被 Indexer/dApp 监听 → 用户 UI 显示"已确认"

这就是我们项目里

  • Arbitrageur.t.sol 输出 gas: 487252 —— 来自 ④ 全部 EVM 指令累加。
  • LiquidationBot.t.sol 输出 gas: 897176 —— 多了一段 Aave liquidationCall 内部 做了大量 SSTORE(更新借贷状态、清算抵押等),SSTORE 是当之无愧的 gas 杀手。
  • test_Arb_Reverts_When_NoNaturalOpportunity 输出 gas: 271552 —— 撞了 require(finalBalance >= owed)状态回滚 + 仍付 27 万 gas。这就是我们一直在说的 "失败只亏 gas"。

7. 常见误区(逐条拍死)

误区 实情
"Gas 只是防垃圾" 还兼计算定价 + 停机问题工程化解决(钱花完即停)
"revert 会退 gas" 不退已用 gas。退的是"状态",不是"算费"。assert 还会消尽剩余 gas(罕用)
"所有 tx 同 fee" 错。base fee 按拥挤度协议自调;tip 你出。MEV bundle 通常付高 tip 抢位
"gasUsed 总等于 gasLimit" 一般 gasUsed < limit;若相等很可能 OOG(out of gas)自动 revert——日志看 status=0
"EIP-1559 后 proposer 拿全部 fee" 反了。base fee 烧、tip 给 proposer。MEV 还可走 coinbase.transfer 另付(04 §5)
"Storage 和 Memory 一样贵" 错三个数量级。SSTORE 新写 20k gas,MSTORE 几 gas。写状态是合约里最贵的事,能少写就少写
"Stack too deep 是 Solidity bug" EVM 栈 16 槽窗口约束的体现。viaIR 用 Yul IR 缓解。我们 foundry.tomlvia_ir = true 就为这个
"所有 EVM 链 gas 一样" 不一样。L2(Optimism / Arbitrum / Base)等加了 L1 数据可用性费,effectiveGasPrice 模型也不同;EVM opcode 表大体一致但 gas schedule 可能改
"把 gasLimit 设很高就万事大吉" gasLimit 决定预付封顶单 tx 最高消耗;设太高浪费 ETH 预占(虽然多余会退),且不能突破区块 gas limit
"我可以靠死循环卡死链" 跑到 gasLimit 自动停。整链 gas limit 也卡死单 tx 最大 ≈ 整块上限的某比例
"block.coinbase = base fee 接收方" 不是。base fee 烧掉、tip + coinbase.transfer 才是 proposer 收的(04 §5 + §6)

8. 与本项目实践的对应

把项目里每个跟 gas/EVM 沾边的具体数字接回模型:

  • foundry.toml: via_ir = true, optimizer = true, optimizer_runs = 200 = §1 第 5 问, 解决 MultiPoolArbitrageur.executeOperation 局部变量超 16-slot 可寻址窗口的 "Stack too deep"。viaIR 走 Yul → 寄存器式分配,把栈压力卸掉。
  • forge test 输出的 gas 数字
    • FlashLoanHelloWorld.t.sol :test_FlashLoan_1_WETH = 359,816 —— Aave 内部 资金调度 + 我们一次 approve;Aave 本身的 storage 更新占了大头。
    • Arbitrageur.t.sol : test_Arb_Succeeds_AfterVictimSkewsUniV2 = 487,252 —— flash loan + 两次 V2 swap(每次 pair 内 SSTORE 更新 reserves)+ 额外的 transfer。
    • LiquidationBot.t.sol : test_Liquidation_FlashLoanFunded = 897,176 —— 多了 Aave liquidationCall 内部一系列重操作(借贷状态、利息累积、抵押转移)。
    • test_Arb_Reverts_When_NoNaturalOpportunity = 271,552 —— 跑到第三步 require(finalBalance >= owed) 撞墙 ⇒ 状态全回滚 + 仍付 27 万 gas。 这是我们整个项目反复强调的"原子失败只亏 gas"的具体数。
  • demo-flashbots.ts
    maxFeePerGas: parseUnits("50", "gwei"),
    maxPriorityFeePerGas: parseUnits("2", "gwei"),
    
    = §4.3 EIP-1559 双价制:愿付上限 50 gwei、给 proposer 的小费 2 gwei; effective 单价 = min(50, baseFee + 2)。这正是 Flashbots relay 模拟时算出 "insufficient funds for gas * price + value: have 0 want 1050000000000000" 那 0.00105 ETH = 50 gwei × 21000 的来源。
  • BundleExecutor.solblock.coinbase.transfer(_ethAmountToCoinbase) = §4.3 EIP-1559 之外的"直接给 proposer 塞钱"通道。base fee 已被烧、tip 已结算后,bundle 还可以再走 coinbase 转账多塞——这正是 simple-arbitrage 模式的根。
  • evm_setAutomine / evm_snapshot / evm_revert(Anvil) = §6 第 ④ 步执行环境的 本地等价物;让我们能在不动主网状态的前提下排演完整 EVM 执行(搭 03 §4.5 state root + 06 §6.2 原子回滚一起看)。
  • "snapshot/revert 与 EVM 原子性是两回事,别混":EVM 自带的原子性是单 tx 内的 状态回滚;anvil snapshot/revert 是把整个链状态回滚到一个之前的标记点(Phase 5 step 3a 我们 simulateBundle 用的就是后者)。

→ 读完 07,回头看 forge 输出每行 gas: NNNNN,从"数字"升级为 "每一千 gas 对应几条 SLOAD/SSTORE/CALL"的成本剖析能力。


9. 自检问题(答得出才算过)

  1. EVM 为什么是"确定性"虚拟机?这一点为什么是状态根(03 §4.5)和 forge fork(03 §8)可信的前提?
  2. Gas 同时解决了哪三个问题?为什么"光防垃圾"不准确?
  3. EIP-1559 双价制 = ? effectiveGasPrice 的精确公式怎么写?base fee 去了哪、tip 去了哪?
  4. require 失败后我的 gas 退吗?退什么、不退什么?这就是项目里所谓"失败只亏 gas",准确含义是?
  5. "Stack too deep" 是 Solidity 的 bug 吗?为什么开 via_ir = true 就能解?
  6. gasUsed 不同的本质来源是什么?为什么我们 LiquidationBot 的 897k 比 Arbitrageur 的 487k 高这么多——能定性归因到具体几类操作上吗?
  7. block.coinbase.transfer 给 proposer 塞钱,与 priority fee 给 proposer 是同一件事吗?为什么 Flashbots 模式用前者?
  8. evm_snapshot/evm_revert(Anvil)和 EVM 内 require revert,这两个"回滚"是同一个吗?区别在哪?

── Q&A / 更正记录区(按日期追加)──

规范见 README.md §0。质疑/反馈/纠错按日期累积,不抹错误轨迹。

  • (2026-05-20 建立。暂无 Q&A。)

  • (2026-07-27) 问:详细讲解交易中的耗费(gas)和结果,以及对应的收据/日志数据和意义。

    答(补充本篇 gas 机制 + 收据/日志结构,未更正原文;完整版含可拖动交互计算器见 ../total/Q&A/2026-07-27-链上账本手记.html 第四、五节):

    gas 三层账:① 起步价(intrinsic gas)——每笔交易固定 21000 gas,外加 calldata 每个零字节 4 gas、每个非零字节 16 gas,这解释了为什么调用参数越长起步就越贵,跟合约逻辑本身无关; ② 逐指令计价——简单运算如 ADD 只要 3 gas,读一个从没读过的 storage 槽(cold access)要 2100 gas,同一笔交易里读过后再读(warm)只要 100 gas,把一个原本是 0 的槽第一次写非零值要 20000 gas (最贵的常规操作之一,是 gas 优化聚焦"减少 storage 写入"的原因);③ 退款——非零槽清零会退一部分 gas,但退款总额封顶在"实耗的 1/5"(EIP-3529),防止"先占满 storage 再清空"骗退款套利。

    常见误区纠正gasLimit 只是愿意付的上限,实际按 gasUsed 结算,差额从来没被收过, 不存在"退还";真正要注意的是 revert 依然要付费——执行到失败点为止已消耗的 gas 是真付出去的。

    交易收据字段status(0x1 成功/0x0 失败,Byzantium 之后才有)、gasUsed/ cumulativeGasUsedeffectiveGasPrice(= min(maxFeePerGas, baseFee+maxPriorityFeePerGas))、 contractAddress(部署交易的新地址)、logs[]logsBloom(概率型过滤器,不用扫描全部日志就能 快速判断某类事件存不存在于这个块)。

    事件日志结构:以 Transfer(address indexed from, address indexed to, uint256 value) 为例, topics[0] = keccak256("Transfer(address,address,uint256)")(事件签名哈希,固定值), topics[1]/topics[2] 是 indexed 参数(左补零到 32 字节),data 是非 indexed 参数的 ABI 编码。 只有 indexed 参数才能被节点用哈希索引快速过滤(最多 3 个),非 indexed 参数虽然也在日志里, 但只能整段下载 data 再解码,没法作为过滤条件——这是区块浏览器/subgraph/套利机器人订阅链上事件的 唯一入口机制。