合约间交互与事件
单个合约再强也是孤岛。真正的 DeFi、NFT 市场都是几十个合约相互调用编织出来的。这一章讲合约怎么调别的合约(以及那个危险又强大的 delegatecall),再讲合约怎么"对外广播"发生了什么——事件与日志,它是前端和索引器感知链上世界的唯一窗口。
合约调合约:三种底层方式
一个合约调用另一个合约,底层有三种 opcode,语义差别巨大,尤其是执行上下文归属:
| 方式 | 代码来自 | storage 改谁的 | msg.sender 是谁 | 用途 |
|---|---|---|---|---|
call | 被调合约 B | B 自己的 | 调用方 A | 常规合约间调用 |
staticcall | B | 不能改(只读) | A | 只读调用(view) |
delegatecall | B 的代码 | A 自己的! | A 的调用者 | 代理 / 库(第 17 章) |
关键在最后一行:delegatecall 借用 B 的代码,却在 A 的存储上执行——就像"把 B 的函数当成 A 自己的方法来跑"。这是可升级合约(代理模式)的核心魔法,也是最容易出安全事故的地方,第 17 章专门讲。
普通 call ≈ 跨进程调用另一个 Service,对方在自己的上下文里执行,改自己的数据。delegatecall 更像动态加载一段代码到你自己的进程里执行(想想 DexClassLoader 加载外部 dex 后在本进程跑)——它能读写你的字段。威力大,风险也大:加载错了代码,等于让别人改你家数据库。
用接口优雅地调用
日常你不会手写 call,而是用 interface 声明对方长啥样,让编译器帮你编码 calldata:
interface IERC20 { function transfer(address to, uint256 amt) external returns (bool); function balanceOf(address a) external view returns (uint256); } contract Payer { function pay(address token, address to, uint256 amt) external { // 拿到某代币合约的引用,像调本地方法一样调它 IERC20(token).transfer(to, amt); } }
IERC20(token) 把一个地址"当作"实现了该接口的合约。编译器据此生成正确的选择器和 calldata。你只需要知道对方的接口(不需要它的源码),就能与之交互——这正是可组合性(composability)的来源:任意合约可以调任意已部署合约,像乐高一样拼装。
外部调用是重入攻击的门。当你 call 一个外部地址(尤其转 ETH 或调未知合约),控制权会交给对方,对方可能在返回前反过来再调你——这就是重入。永远遵循 checks-effects-interactions 顺序:先检查、再改自己状态、最后才做外部调用。第 16 章会用一个 Demo 让你亲眼看重入怎么把金库掏空。
事件:合约对外界的广播
合约不能主动"推送"消息给前端(它没法发起网络请求)。它能做的是 emit 一个事件,把数据写进交易回执的 日志(log)里。前端、后端索引器通过 RPC 订阅或查询这些日志,来感知"链上发生了什么"。
event Transfer( address indexed from, // indexed:可被按此字段过滤检索 address indexed to, uint256 value // 非 indexed:只存数据,不可过滤 ); function transfer(address to, uint256 amt) public { balance[msg.sender] -= amt; balance[to] += amt; emit Transfer(msg.sender, to, amt); // 广播这次转账 }
indexed 的意义
一个事件最多 3 个 indexed 参数,它们被存进日志的"topics"(可高效过滤),其余参数存进"data"(要读全量日志才能解)。想查"所有转给 Alice 的记录",就靠 to 是 indexed;要是没标 indexed,你就没法按它过滤。设计事件时,把"别人会拿来做查询条件"的字段标成 indexed。
事件 = 结构化日志 + 可订阅的广播,合起来像 Log.i 遇上 LiveData/Flow。合约 emit 就是打一条结构化日志,前端用 eth_getLogs(历史)或 WebSocket 订阅(实时)去"观察"它,像观察一个数据流。indexed 字段则像给日志加了可检索索引。第 20 章做 dApp 时,你就是靠监听事件来实时刷新界面的。
日志比 storage 便宜得多
一个重要的工程取舍:日志不能被合约读取(合约看不到自己或别人 emit 的日志),它只给链下用。但它比 storage 便宜一个数量级。所以模式是:需要合约逻辑用到的数据,存 storage;只给前端/索引器看的历史记录,发事件。比如"用户的当前余额"存 storage(合约要用它做校验),"每一笔转账历史"发事件(合约不需要回看,前端用它渲染流水)。
两条主线:①合约间用 interface 调用实现可组合性;call 改被调方存储,delegatecall 借代码改自己存储(危险但强大);外部调用要防重入。②事件是合约对链下世界的唯一广播渠道,写进日志、比 storage 便宜,indexed 字段可高效过滤,前端靠它感知链上变化。下一章我们把工程化补上——用 Foundry 给这些合约写测试、跑部署,像你用 Gradle + JUnit 一样。
本章小结
- call/staticcall/delegatecall 三者在"代码来自谁、改谁的 storage、msg.sender 是谁"上不同;delegatecall 是代理模式核心。
- 用 interface 声明对方形状即可调用任意已部署合约,这是可组合性的来源。
- 外部调用会交出控制权,须按 checks-effects-interactions 防重入。
- 事件写进日志、是合约对链下的广播;indexed 可过滤;日志比 storage 便宜但合约读不到。