第二层第三篇。06 说"账户那一行可以装一段 code";07 说"EVM 一步步跑那段 code 并收 gas"。 这一篇答那段 code 到底长什么样、它怎么被调用、Solidity 的 interface / is / require / emit / external call 在 EVM 层各自落成什么、为什么我们项目里四份合约(FlashLoanHelloWorld / Arbitrageur / MultiPoolArbitrageur / LiquidationBot)写得是那个形状。沿用 01–07 的村庄画面。 结构见 README.md §1。


0. 一句话本质

智能合约 = 部署到某个地址上的"公开、确定性、不可篡改"程序;任何人按规则 (4 字节函数选择器 + ABI 编码参数)投币就能调用,EVM 按指令逐条执行、收 gas、 落账或回滚。interface / is / emit / require 只是 Solidity 帮你拼凑 EVM 那几个 真正存在的原语(CALL / LOG / REVERT)的语法糖。


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

06 + 07 留下的真正落地疑问:

  1. IERC20(usdc).approve(pool, x) 调出去后,EVM 怎么知道去 USDC 合约里执行哪段代码? ⇒ "function 名"在 EVM 里根本不存在,存在的只有 4 字节选择器。approvekeccak256("approve(address,uint256)")[:4] = 0x095ea7b3。所有合约用同一张选择器表。
  2. is IFlashLoanSimpleReceiver 不是说"运行时多态"吗?为什么 Aave 能"回调"我? ⇒ 它不是运行时多态。继承 interface 只是逼你在编译期把那个名字的函数实现出来—— Aave 只会拿固定选择器 executeOperation(...) 调你的地址,你必须有这个 4 字节入口。
  3. Constructor 跑完去哪了?合约 code 又是怎么落到 account.code 字段的(06 §4.3)? ⇒ 部署是一笔特殊 tx:to=nulldata=initcode。EVM 跑 initcode(含 constructor), 返回值就是真正存到链上的 runtime bytecode。Constructor 跑完即丢。
  4. emit FlashLoaned(...) 之后,事件存哪了?是 storage 吗? ⇒ 不是 storage(07 § 那是合约里最贵的 op)。事件用的是 LOG0~LOG4 opcode, 写进该 tx 的 receipt,链上节点用 bloom filter 索引;存得很便宜,链下能查、合约里读不到
  5. require(x > 0, "BAD") 失败后,那串 "BAD" 是怎么到调用方那里的?assert 和它有什么区别? ⇒ Solidity 把 require(_,"BAD") 编成 REVERT 指令 + 返回 Error(string) 编码: 0x08c379a0 ++ ABI-encode("BAD")。assert 在新版 Solidity 里也走 REVERT 但带 panic 编码 0x4e487b71 + 错误码。
  6. DELEGATECALL 和 CALL 到底差什么?为什么"代理合约升级"靠它? ⇒ DELEGATECALL = 借壳:用对方的 code,但 storage / msg.sender / msg.value 全保持是我。 代理合约把所有调用 DELEGATECALL 到 impl,自己只存数据——升级时换 impl 地址即可。
  7. console.log("...") 在 Foundry test 里能用,但部署到主网会发生什么? ⇒ Foundry 把它编译成对地址 0x000000000000000000636F6e736F6c652e6c6f67 的 STATICCALL。 主网那地址没合约 → STATICCALL 返回空 → 调用静默返回,不会让你的合约失败,但白烧一点 gas。
  8. 既然部署不可篡改,那"合约升级"是什么把戏? ⇒ 你不能改 bytecode,但可以用代理 + DELEGATECALL 把"该跑哪份 impl"改成可变指针。 selfdestruct 在 Cancun 升级后基本只剩"清零 ETH",不再删 code。

→ 这一篇答全部。


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

把"代码 = 法律"工程化:一份程序部署后,其行为完全由字节码 + EVM 规则(07)+ storage 状态(06)三者决定; 不依赖任何人继续运营、不可被部署者悄悄修改(除非显式架了代理)、被全网节点独立重放并校验一致。 调用方与被调用方之间只通过 4 字节选择器 + ABI 编码这种"机器对机器"的窄接口耦合,名字、注释、变量名链上完全消失。

→ 这是为什么我们项目里 Arbitrageur.executeOperation(...) 不用注册到 Aave、也不用授权—— Aave 只关心"你地址上有没有那个 4 字节入口、它返回不返回 true"。接口是机械协议,不是社交合约


3. 类比:村庄的"自动售货机"再放大

06 已经把合约账户类比成"公共自动售货机"。08 看到机器内部:

┌── 一台自动售货机的剖面 ──────────────────────────────────┐
│                                                          │
│  外壳上的按钮贴纸               内部接线                  │
│  ┌──────────────────┐         ┌──────────────────────┐  │
│  │ "approve"        │ ◀────── │ if selector==0x095ea7b3│  │
│  │ "transfer"       │ ◀────── │   jump → approve_impl  │  │
│  │ "swap"           │ ◀────── │ if selector==0x022c0d9f│  │
│  │ ...              │         │   jump → swap_impl     │  │
│  └──────────────────┘         │ else REVERT (没这功能) │  │
│                               └──────────────────────┘  │
│                                                          │
│  你**真正递进去的**:4 字节选择器 + ABI 编码的参数(calldata)│
│  机器**真正认的**:只有那 4 字节                          │
│  按钮上的人类文字(approve / transfer / swap):链上根本不存在│
│                                                          │
│  机器还能:                                              │
│    - 调旁边另一台机器(CALL,给对方独立账本)             │
│    - 只问对方一眼(STATICCALL,对方不许写自己账本)       │
│    - 借用对方的电路、但用自己的账本(DELEGATECALL,代理升级)│
│    - 往透明公告栏贴小纸条(LOG/Event,链下能看,自己读不到)│
│    - 中途撞墙就把所有产品和零钱退回(REVERT,原子性)     │
│    - 但**电费照付**(07 §4.4 失败只亏 gas)              │
└─────────────────────────────────────────────────────────┘

→ 一图说尽 Solidity 所有那些花哨语法(is / external / view / pure / emit / require / delegatecall)在 EVM 层的真身。


4. 核心关键点(5 条)

  1. 没有"函数名",只有"选择器"funcSig = "approve(address,uint256)"selector = keccak256(funcSig)[:4] = 0x095ea7b3。 合约 bytecode 起手就是个 4-byte 跳转表:if calldata[:4] == 0x095e... jump to approve_impl。 interface / is 都只为让编译器在你和对方之间用同一张选择器表——不是运行时多态。

  2. 部署 = 一种特殊 txto = nulldata = initcode。EVM 跑 initcode(含 constructor),其返回值才是真正落到 account.code 的 runtime bytecode。地址由 06 §4.5 的 CREATE 公式确定。Constructor 跑完即丢, 主网上的合约没有 constructor 字节码

  3. 四种调用,决定"谁的账本被改"

    操作 用谁的 code 用谁的 storage 用谁的 msg.sender
    CALL callee callee caller
    STATICCALL callee callee 只读 caller
    DELEGATECALL callee caller(借壳) 保持原始(即更外层)
    CALLCODE(已弃用) callee caller caller
    • CALL 99% 场景(我们项目里 swap / approve / liquidationCall 全部)
    • STATICCALL = view/pure 函数 + 我们 getReserves()
    • DELEGATECALL = 代理升级、库合约
  4. Events / Logs 不是 storageemit 编译成 LOG0~LOG4 opcode(按 indexed 字段个数选);写入该 tx 的 receipt, 有 bloom filter 加速链下查询。合约自己读不回这些 log(只能写)。 gas 远比 SSTORE 便宜——所以我们才能 emit FlashLoaned(...) 不肉疼。

  5. Revert 有一个 opcode、三种数据编码

    • revert("BAD") / require(_, "BAD") → REVERT + 0x08c379a0(Error(string)) + ABI("BAD")
    • 自定义错误 error InsufficientProfit(uint256 got, uint256 owed); revert InsufficientProfit(...) → REVERT + keccak256("InsufficientProfit(uint256,uint256)")[:4] + ABI 参数 ← gas 最省
    • assert(x) / 0 除 / 数组越界等 → REVERT + 0x4e487b71(Panic(uint256)) + 错误码
    • 三者状态都全回滚(07 §4.4),仅返回数据的解读方式不同

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

名词 一句话 类比
selector(函数选择器) keccak256("funcSig")[:4],唯一识别一个函数 售货机按钮的 4 字节贴纸条码
funcSig(函数签名) name(type1,type2,...),参数名不算 按键的标准化名字
ABI 应用二进制接口,规定参数怎么编码进 calldata、返回值怎么编码出来 投币口的纸条折叠规范
calldata tx 携带的只读输入字节流:[selector(4)] [ABI 编码参数] 投进口子的那张纸条
initcode / runtime bytecode 部署时跑的 / 部署后留在链上的 装配脚本 / 装配完的电路板
constructor 部署时跑一次的特殊函数 工厂装配工序
modifier 函数前后插一段检查代码的语法糖 按下按钮前先扫卡片
fallback / receive 没匹配选择器时 / 纯收 ETH 时的兜底 没贴纸的按键的默认行为
interface 只有函数签名、无实现的"协议定义" 一份按键约定,不含线路图
abstract / virtual / override 编译期多态机制 编排哪份按钮在子合约被哪份替换
CALL / STATICCALL / DELEGATECALL 三种外部调用方式 §4.3 表
msg.sender / tx.origin 直接调用者 / 整条 tx 的最初签名者 上一只手 / 最早投币那只手
block.coinbase 出块者地址(04 §5) 本块执笔人
REVERT / require / assert / custom error §4.5 三种编码、一个 opcode 撞墙退款
Event / Log / Topic LOG opcode 写入 receipt;topic 是可被 bloom filter 索引的字段 透明公告栏便条,可按颜色筛
selfdestruct Cancun 后只保留"清零 ETH 余额",不再删 code 售货机断电(机壳仍在)
proxy / impl 代理 + 实现,DELEGATECALL 实现可升级 控制台 + 可换主板

6. 一个完整过程走一遍(端到端)

pnpm demoArbitrageur 这一笔走完整生命周期——从源代码 → 部署 → 被调 → 回调 → 落账:

6.1 编译期:源代码 → bytecode + ABI

contract Arbitrageur is IFlashLoanSimpleReceiver {
    function arb(address[2] calldata routers, address tokenIn, uint256 amount) external { ... }
    function executeOperation(address asset, uint256 amount, uint256 premium, address initiator, bytes calldata params)
        external returns (bool) { ... }
}

solc 干的事:

  • 算出选择器:
    • arb(address[2],address,uint256)keccak256(...)[:4] = 0xxxxxxxxx
    • executeOperation(address,uint256,uint256,address,bytes)0x920f5c84(Aave 协议钦定)
  • 生成 runtime bytecode 起手的跳转表:
    PUSH4 0x920f5c84  CALLDATALOAD  EQ  PUSH2 <exec_jmp>  JUMPI
    PUSH4 0xxxxxxxxx  CALLDATALOAD  EQ  PUSH2 <arb_jmp>   JUMPI
    REVERT  ; 没匹配 = 这合约没这函数
    
  • IFlashLoanSimpleReceiver 里那个 executeOperation 选择器强制要求你必须实现—— 这就是"接口"的全部作用。链上跑的时候没有任何 interface 概念

6.2 部署:forge script DeployMultiPool

tx = { from=deployer, to=null, data=initcode, nonce=N }

EVM 跑 initcode:

  1. constructor 跑一遍(保存 owner / pool 地址等到 storage)
  2. initcode 末尾 RETURN runtime bytecode
  3. EVM 把 runtime bytecode 写到 account[CREATE_addr].code 字段
  4. CREATE_addr = keccak256(rlp(deployer, nonce))[-20:](06 §4.5)

→ 这是为什么我们 forge script DeployMultiPool 每次都输出 0xDda8187E31Fb73112B650aF9261a956aFc822B51: deployer + nonce 全部固定 ⇒ 地址必然固定。

6.3 调用:pnpm demo 投出 arb tx

tx.to   = 0xDda8...
tx.data = 0xxxxxxxxx                                  ← arb selector (4B)
        + 000…UNI_ROUTER (32B)                        ← routers[0]
        + 000…SUSHI_ROUTER (32B)                      ← routers[1]
        + 000…USDC_ADDRESS (32B)                      ← tokenIn
        + 000…0000064F9C300 (32B)                     ← amount = 26869e6

EVM 加载 0xDda8...code → 跳转表查到 arb selector 命中 → 跳到 arb_impl 入口 → 把 calldata 32 字节 为一组依序压栈 → 开始执行套利逻辑。

6.4 回调:Aave → 我们的 executeOperation

我们的 arb() 第一件事:

POOL.flashLoanSimple(address(this), tokenIn, amount, "", 0);

这等价于一条 CALL:

  • 目标地址 = Aave Pool
  • calldata = flashLoanSimple 选择器 + ABI 编码参数
  • value = 0
  • gas = 剩余 gas 的一定比例

Aave 内部做事顺序:

  1. amount USDC 从自己金库 transfer 到 address(this)(我们)
  2. 回头 CALL 我们address(this).executeOperation(asset, amount, premium, initiator, params)
    • 这里 Aave 是怎么知道要调 executeOperation 的?
    • 答:因为 IFlashLoanSimpleReceiver 接口约定就这个名字 + 这个签名,对应选择器 0x920f5c84。 Aave 硬编码了这个选择器、CALL 我们的地址、检查返回 true。
    • 没有任何"注册"步骤——靠 ABI / selector 单向耦合。
  3. 我们的 executeOperation 跑套利(两次 swap)+ approve Aave 收回本金 + 5bps 溢价
  4. 返回 true 给 Aave
  5. Aave 拉回 amount + premium,结束 flashLoanSimple

→ 这整套回调没有任何注册、订阅、事件总线——纯靠 4 字节选择器约定 + CALL 原语

6.5 落账 + Event

成功后 EVM:

  • 落 storage 改动(reserves、余额等)→ state root 更新(03 §4.5)
  • 把过程中所有 emit Swap(...)emit FlashLoanSimple(...) 等 LOG 写入 receipt
  • gasUsed = 487,252(07 §8)
  • receipt.status = 1

→ Indexer / The Graph / 链下监听服务 看到 LOG,索引到 UI;合约自己永远读不到这些 LOG(§4.4)。


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

误区 实情
"Smart contract = 智能的合约" 既不智能、也不法律意义上的合约。确定性脚本,名字是历史包袱
"合约按函数名调用" 链上根本没"名字",只有 4 字节选择器
"interface 是运行时多态" 编译期约束 + 选择器对齐。Aave 不"知道"你 is 谁,只 CALL 那 4 字节
"constructor 永远存在" 部署后即被丢弃,链上 runtime bytecode 不含它。再调 constructor 没有这种事
"emit Event 写状态" LOG opcode,写 receipt 不写 storage;合约自己读不回;比 SSTORE 便宜很多
"require / assert / revert 是三种东西" 同一个 REVERT opcode、三种返回数据编码(§4.5)
"Custom error 比 require(_,'BAD') 没区别" 错。custom error 只发选择器 + 参数,显著省 gas——大量合约现在都换 custom error
"DELEGATECALL = CALL" 错。借代码、用自己 storage、保留原 msg.sender——代理升级的根
"合约部署后能被修改" code 不能改。代理 + 可变 impl 指针可达到事实上的可升级(这是另一回事)
"selfdestruct 能把合约从链上抹掉" Cancun(2024)后只保留"清零 ETH",code 仍在
"console.log 会上主网" Foundry-only。编译目标主网时是无害 STATICCALL 到一个无人地址;不影响逻辑,但白烧 gas,记得 prod 删
"external 和 public 没区别" 同一可见性,但 external 函数从 calldata 直接读参数(省 gas),public 会拷到 memory
"view / pure 在 EVM 里是强约束" 不是。它们是 Solidity 编译期检查。被外部 STATICCALL 调时才真强制(写 storage → revert)
"msg.sender 总是用户的钱包地址" 错。中间有合约的话 msg.sender 是上一只手;tx.origin 才是最初 EOA。不要用 tx.origin 做权限(钓鱼陷阱)

8. 与本项目实践的对应

把项目里出现的每个 Solidity 写法接回 EVM 原语:

我们四份合约里典型的"接口 + 回调"模式

  • FlashLoanHelloWorld is IFlashLoanSimpleReceiver(§4.1) ≡ 编译器逼你实现 executeOperation 选择器 0x920f5c84。 Aave 在 flashLoanSimple 里硬编码 CALL 这个选择器 → 你必须有。
  • Arbitrageur.executeOperation(asset, amount, premium, initiator, params) ≡ 同上。Aave 调你的入口;你跑套利;返回 true 表示你已经 approve 它把本金 + premium 拉回。
  • MultiPoolArbitrageur.executeOperation(...) ≡ 同上 + 内部多池路由(这就是 07 §1.5 "Stack too deep" 的源头,逻辑变量太多)。
  • LiquidationBot.executeOperation(...) ≡ 同上 + 调 Aave 的 liquidationCall(...) 做清算(897k gas 的来源,07 §8)。

我们四份合约里典型的"external call"形态

  • IERC20(token).approve(pool, amount) = CALL
    • selector = 0x095ea7b3
    • 标准 ERC-20(09 待写)
    • 在闪电贷 callback 里必须先 approve Pool 把本金 + premium 拉回,否则 Pool 那边 transferFrom 会 revert ⇒ 整笔 tx 回滚
  • IUniswapV2Router02(router).swapExactTokensForTokens(...) = CALL
    • selector = 0x38ed1739
    • 内部多步:转账 → swap → 转账
  • IUniswapV2Pair(pair).getReserves() = STATICCALL(编译器看 view 自动选)
    • selector = 0x0902f1ac
    • 只读,不能改 storage;这是为什么 ComparePools.s.sol 可以纯只读跑
  • IUniswapV2Pair(pair).swap(amount0Out, amount1Out, to, data) = CALL
    • selector = 0x022c0d9f
    • 这是 V2 最底层的 swap 原语,会改 reserves storage(SSTORE 是 gas 大头)

我们的 require / revert

  • require(finalBalance >= owed, "PROFIT_FAIL") ⇒ REVERT + Error(string) 0x08c379a0 + ABI("PROFIT_FAIL")
  • 这就是 test_Arb_Reverts_When_NoNaturalOpportunity 输出的 revert reason 怎么显示成 "PROFIT_FAIL" 的——Foundry 在收到 revert data 后按 0x08c379a0 头解码出字符串。
  • 写新合约时尽量用 custom error(§4.5),可省下数十~几百 gas/次。
  • test_Arb_Reverts... 用了 271,552 gas 然后回滚 ⇒ 07 §8 "失败只亏 gas"具体数。

我们的 emit

  • 项目当前合约 emit 较少(核心套利不依赖事件去触发);但 Aave Pool 的 emit 我们每跑一次都触发, 这些 LOG 进了 receipt,链下用 eth_getLogs 能查到。
  • 如果之后我们要让 indexer 监听套利成功,用 event 是对的选择——比把状态写 storage 便宜得多。

console.log

  • 我们 test 里 console.log("arb done, profit=", profit)forge test 时打印, 但部署到主网会变成对地址 0x000000000000000000636F6e736F6c652e6c6f67 的 STATICCALL—— 那是 "console.log" 这 7 个字符的 ASCII 直接当地址。
  • 上线前记得删,否则白烧 gas。

CREATE / 部署地址确定性

  • forge script DeployMultiPool 跑同样 deployer + 同样 nonce ⇒ 同样 0xDda8... 地址(06 §4.5 公式)。
  • 这就是为什么我们的 watcher / demo 里能硬编码合约地址而不需要查表。

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

  1. IERC20(usdc).approve(spender, x) 在 EVM 层到底发生了什么?把 selector + ABI 编码到字节级写出来。
  2. is IFlashLoanSimpleReceiver 给你的运行时收益是 0 还是某个具体能力?为什么?
  3. 部署一份合约的 tx 跟一般 tx 长得有什么不同?constructor 跑完后去哪了?
  4. CALL / STATICCALL / DELEGATECALL 三者在"谁的 storage 被改"和"msg.sender 是谁"上各自如何?哪种被代理合约 / 库合约用?
  5. emit Swap(...) 写在 storage 里吗?合约自己能 read back 吗?为什么这种设计很合理?
  6. require(_,"BAD")revert CustomError(...) 在 EVM 层是同一个 opcode 吗?为什么后者推荐?省的是哪部分 gas?
  7. console.log 部署到主网会让交易 revert 吗?它实际是什么?
  8. "升级合约"在 EVM 层是怎样办到的——bytecode 真的能改吗?

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

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

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

  • (2026-07-27) 问:详细讲一下链的书写和部署、构造函数里干了什么、ABI、data,以及实际部署链上 怎么进行函数匹配、参数传递和调用返回。

    答(补充本篇部署/ABI/calldata/dispatch 的底层机制,未更正原文;完整版含可视化字节条见 ../total/Q&A/2026-07-27-链上账本手记.html 第六节 A-E):

    部署与构造函数:solc 编译产出运行时字节码(长期挂在地址上响应调用的代码)+ 创建字节码 (= 运行时字节码 + 只跑一次的构造逻辑,打包在一起)+ ABI。部署交易 to 留空,data 是创建字节码 (构造参数按 ABI 编码后拼在末尾)。构造函数把参数写进 storage,或者直接烧进 immutable (编译期嵌进运行时字节码本身,读取不用 SLOAD,省 gas);跑完用 RETURN 把运行时字节码交出去—— 构造函数逻辑本身从此不再是这个地址代码的一部分,只活在部署这一笔交易里。

    新地址计算CREATE = keccak256(rlp([发送者地址, nonce])) 取后 20 字节;CREATE2 = keccak256(0xff++部署者++salt++keccak256(创建字节码)) 取后 20 字节——CREATE2 的关键价值是部署前就能算出地址(只要创建字节码和 salt 不变),Uniswap V2 交易对地址、ERC-4337 智能账户"先算地址 收币、真正用时才部署"都靠这个。

    ABI:链上只有字节码,没有函数名/参数类型这类人类可读信息,全部记在链下的 ABI JSON 里—— 未验证合约在 Etherscan 上读写面板是灰的,就是因为没有 ABI。编码规则:定长类型补零对齐 32 字节; 变长类型用"偏移量指针+长度+数据本体"三段式,本体统一堆在 calldata 尾部。

    calldata 与函数匹配(dispatch):前 4 字节 = keccak256(函数签名canonical形式)[:4](选择器, 比如 transfer(address,uint256)0xa9059cbb),之后是逐 32 字节的编码参数。运行时字节码开头是 编译器生成的分派表:CALLDATALOAD 读前 4 字节,一串 EQ+JUMPI(函数多时用二分查找式跳转)比对 已知 selector,命中就跳到对应函数体;全不命中且带 calldata 走 fallback(),纯 ETH 空 calldata 走 receive()

    参数传递与返回:编译期已把每个参数在 calldata 里的字节偏移算死,函数体直接用 CALLDATALOAD/CALLDATACOPY 按偏移取值,变量名编译后已不存在;返回值编码后写进 memory, RETURN(offset,length) 交还,调用方必须按自己预期的类型解码,链不会替你检查对不对。eth_call (只读模拟,不花 gas)和真正发交易走同一套匹配/执行逻辑,差别只在结果要不要写回状态、要不要收费。