07 · EVM / Gas / 交易生命周期
第二层第二篇。06 说"账本一行装 { 余额, nonce, code, storage }", 但那段 code 怎么跑、谁付计算费、按什么单价、失败了怎么算钱——这一篇答。 直接落到项目里反复出现的具体数字:
via_ir = true为什么开、gasUsed = 359816 / 487252 / 897176这些值各自来自哪、maxFeePerGas: 50 gwei / maxPriorityFeePerGas: 2 gwei(demo-flashbots.ts) 是 EIP-1559 哪两件事、requirerevert 为什么"还要付钱"。 沿用 01–06 的村庄画面。结构见README.md§1。
0. 一句话本质
EVM = 一台所有节点都跑同一份代码、按同一规则得出同一答案的世界级计算器。 Gas = 每条 EVM 指令的"按动一次"价格——既给计算定价、又防止有人写死循环把全网卡死。 你为 EVM 做过的工作付钱(gasUsed × 单价),无论结果是成功落入账本还是 revert 整个回滚。
1. 怎么产生的:它解决什么问题
06 留了几个 EVM 层面才能答的具体疑问:
- 合约的"那段 code"由谁跑、按什么规则跑?为什么全网节点都能算出同一答案? ⇒ 一台所有人都同步实现的虚拟机:EVM。
- 公共计算机怎么不被滥用?
写个
while(true)就能把全网卡死——必须给计算定价 ⇒ Gas。 - 付多少?谁定价? EIP-1559 之后是"协议自动调 base fee + 你出小费 tip"的拍卖市场。
requirerevert 之后我的钱白付了? 是的。EVM 的"原子性"承诺是状态全回滚,不是"钱全回滚"——已执行的工作仍要付。forge build报 "Stack too deep" 是什么?为什么开via_ir = true就好了? EVM 栈深 1024 但可寻址窗口只有 16,编译器变量塞不下。via_ir换条 Yul IR 路径优化分配。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 条)
- EVM = 确定性、计费、堆栈式虚拟机。所有节点跑同一实现 ⇒ 同输入必同输出 ⇒ 状态过渡可被全网独立验证(连回 03 state root 一致性)。
- Gas 同时解三个问题:
- 计算定价(不同操作不同成本,SSTORE ≫ SLOAD ≫ ADD)
- DoS 防护(写死循环?超 gasLimit 自动停)
- 停机问题工程化(不需要数学上判停,钱花完即停)
- EIP-1559 双价制:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)- base fee 烧(销毁),按上块拥挤度自动调
- priority fee(tip)给 proposer,由你出价
maxFeePerGas是你愿付的总上限;maxPriorityFeePerGas是其中愿给 proposer 的部分
- 失败 ≠ 免单:revert 把状态全部回滚(这是原子性),但已执行的指令照样付费。 "失败只亏 gas"= 这条;不是"什么都不亏",是"不亏本金,只亏算费"。
- 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 demo 里
arb 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—— 多了一段 AaveliquidationCall内部 做了大量 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.toml 开 via_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 —— 多了 AaveliquidationCall内部一系列重操作(借贷状态、利息累积、抵押转移)。test_Arb_Reverts_When_NoNaturalOpportunity= 271,552 —— 跑到第三步require(finalBalance >= owed)撞墙 ⇒ 状态全回滚 + 仍付 27 万 gas。 这是我们整个项目反复强调的"原子失败只亏 gas"的具体数。
demo-flashbots.ts:
= §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 的来源。maxFeePerGas: parseUnits("50", "gwei"), maxPriorityFeePerGas: parseUnits("2", "gwei"),BundleExecutor.sol里block.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. 自检问题(答得出才算过)
- EVM 为什么是"确定性"虚拟机?这一点为什么是状态根(03 §4.5)和
forge fork(03 §8)可信的前提? - Gas 同时解决了哪三个问题?为什么"光防垃圾"不准确?
- EIP-1559 双价制 = ?
effectiveGasPrice的精确公式怎么写?base fee 去了哪、tip 去了哪? require失败后我的 gas 退吗?退什么、不退什么?这就是项目里所谓"失败只亏 gas",准确含义是?- "Stack too deep" 是 Solidity 的 bug 吗?为什么开
via_ir = true就能解? gasUsed不同的本质来源是什么?为什么我们LiquidationBot的 897k 比Arbitrageur的 487k 高这么多——能定性归因到具体几类操作上吗?block.coinbase.transfer给 proposer 塞钱,与 priority fee 给 proposer 是同一件事吗?为什么 Flashbots 模式用前者?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/cumulativeGasUsed、effectiveGasPrice(=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/套利机器人订阅链上事件的 唯一入口机制。