08 · 智能合约:部署、调用、Revert、回调
第二层第三篇。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 留下的真正落地疑问:
IERC20(usdc).approve(pool, x)调出去后,EVM 怎么知道去 USDC 合约里执行哪段代码? ⇒ "function 名"在 EVM 里根本不存在,存在的只有 4 字节选择器。approve≡keccak256("approve(address,uint256)")[:4] = 0x095ea7b3。所有合约用同一张选择器表。is IFlashLoanSimpleReceiver不是说"运行时多态"吗?为什么 Aave 能"回调"我? ⇒ 它不是运行时多态。继承 interface 只是逼你在编译期把那个名字的函数实现出来—— Aave 只会拿固定选择器executeOperation(...)调你的地址,你必须有这个 4 字节入口。- Constructor 跑完去哪了?合约 code 又是怎么落到
account.code字段的(06 §4.3)? ⇒ 部署是一笔特殊 tx:to=null、data=initcode。EVM 跑 initcode(含 constructor), 返回值就是真正存到链上的 runtime bytecode。Constructor 跑完即丢。 emit FlashLoaned(...)之后,事件存哪了?是 storage 吗? ⇒ 不是 storage(07 § 那是合约里最贵的 op)。事件用的是LOG0~LOG4opcode, 写进该 tx 的 receipt,链上节点用 bloom filter 索引;存得很便宜,链下能查、合约里读不到。require(x > 0, "BAD")失败后,那串 "BAD" 是怎么到调用方那里的?assert和它有什么区别? ⇒ Solidity 把require(_,"BAD")编成REVERT指令 + 返回Error(string)编码:0x08c379a0++ ABI-encode("BAD")。assert在新版 Solidity 里也走 REVERT 但带 panic 编码0x4e487b71+ 错误码。- DELEGATECALL 和 CALL 到底差什么?为什么"代理合约升级"靠它? ⇒ DELEGATECALL = 借壳:用对方的 code,但 storage / msg.sender / msg.value 全保持是我。 代理合约把所有调用 DELEGATECALL 到 impl,自己只存数据——升级时换 impl 地址即可。
console.log("...")在 Foundry test 里能用,但部署到主网会发生什么? ⇒ Foundry 把它编译成对地址0x000000000000000000636F6e736F6c652e6c6f67的 STATICCALL。 主网那地址没合约 → STATICCALL 返回空 → 调用静默返回,不会让你的合约失败,但白烧一点 gas。- 既然部署不可篡改,那"合约升级"是什么把戏?
⇒ 你不能改 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 条)
-
没有"函数名",只有"选择器"。
funcSig = "approve(address,uint256)"→selector = keccak256(funcSig)[:4] = 0x095ea7b3。 合约 bytecode 起手就是个 4-byte 跳转表:ifcalldata[:4] == 0x095e...jump to approve_impl。interface/is都只为让编译器在你和对方之间用同一张选择器表——不是运行时多态。 -
部署 = 一种特殊 tx。
to = null,data = initcode。EVM 跑 initcode(含 constructor),其返回值才是真正落到account.code的 runtime bytecode。地址由 06 §4.5 的 CREATE 公式确定。Constructor 跑完即丢, 主网上的合约没有 constructor 字节码。 -
四种调用,决定"谁的账本被改":
操作 用谁的 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 = 代理升级、库合约
-
Events / Logs 不是 storage。
emit编译成 LOG0~LOG4 opcode(按 indexed 字段个数选);写入该 tx 的 receipt, 有 bloom filter 加速链下查询。合约自己读不回这些 log(只能写)。 gas 远比 SSTORE 便宜——所以我们才能emit FlashLoaned(...)不肉疼。 -
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 demo 里 Arbitrageur 这一笔走完整生命周期——从源代码 → 部署 → 被调 → 回调 → 落账:
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] = 0xxxxxxxxxexecuteOperation(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:
- constructor 跑一遍(保存 owner / pool 地址等到 storage)
- initcode 末尾 RETURN runtime bytecode
- EVM 把 runtime bytecode 写到
account[CREATE_addr].code字段 - 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 内部做事顺序:
- 把
amountUSDC 从自己金库 transfer 到address(this)(我们) - 回头 CALL 我们:
address(this).executeOperation(asset, amount, premium, initiator, params)- 这里 Aave 是怎么知道要调
executeOperation的? - 答:因为 IFlashLoanSimpleReceiver 接口约定就这个名字 + 这个签名,对应选择器
0x920f5c84。 Aave 硬编码了这个选择器、CALL 我们的地址、检查返回 true。 - 没有任何"注册"步骤——靠 ABI / selector 单向耦合。
- 这里 Aave 是怎么知道要调
- 我们的 executeOperation 跑套利(两次 swap)+ approve Aave 收回本金 + 5bps 溢价
- 返回
true给 Aave - 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 回滚
- selector =
IUniswapV2Router02(router).swapExactTokensForTokens(...)= CALL- selector =
0x38ed1739 - 内部多步:转账 → swap → 转账
- selector =
IUniswapV2Pair(pair).getReserves()= STATICCALL(编译器看view自动选)- selector =
0x0902f1ac - 只读,不能改 storage;这是为什么
ComparePools.s.sol可以纯只读跑
- selector =
IUniswapV2Pair(pair).swap(amount0Out, amount1Out, to, data)= CALL- selector =
0x022c0d9f - 这是 V2 最底层的 swap 原语,会改 reserves storage(SSTORE 是 gas 大头)
- selector =
我们的 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. 自检问题(答得出才算过)
IERC20(usdc).approve(spender, x)在 EVM 层到底发生了什么?把 selector + ABI 编码到字节级写出来。is IFlashLoanSimpleReceiver给你的运行时收益是 0 还是某个具体能力?为什么?- 部署一份合约的 tx 跟一般 tx 长得有什么不同?constructor 跑完后去哪了?
- CALL / STATICCALL / DELEGATECALL 三者在"谁的 storage 被改"和"msg.sender 是谁"上各自如何?哪种被代理合约 / 库合约用?
emit Swap(...)写在 storage 里吗?合约自己能 read back 吗?为什么这种设计很合理?require(_,"BAD")和revert CustomError(...)在 EVM 层是同一个 opcode 吗?为什么后者推荐?省的是哪部分 gas?console.log部署到主网会让交易 revert 吗?它实际是什么?- "升级合约"在 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)和真正发交易走同一套匹配/执行逻辑,差别只在结果要不要写回状态、要不要收费。