上链ON-CHAIN BLOCK 12 / 26
卷三 · 合约工程BLOCK 12

合约间交互与事件


单个合约再强也是孤岛。真正的 DeFi、NFT 市场都是几十个合约相互调用编织出来的。这一章讲合约怎么调别的合约(以及那个危险又强大的 delegatecall),再讲合约怎么"对外广播"发生了什么——事件与日志,它是前端和索引器感知链上世界的唯一窗口。

合约调合约:三种底层方式

一个合约调用另一个合约,底层有三种 opcode,语义差别巨大,尤其是执行上下文归属:

方式代码来自storage 改谁的msg.sender 是谁用途
call被调合约 BB 自己的调用方 A常规合约间调用
staticcallB不能改(只读)A只读调用(view)
delegatecallB 的代码A 自己的!A 的调用者代理 / 库(第 17 章)

关键在最后一行:delegatecall 借用 B 的代码,却在 A 的存储上执行——就像"把 B 的函数当成 A 自己的方法来跑"。这是可升级合约(代理模式)的核心魔法,也是最容易出安全事故的地方,第 17 章专门讲。

Android 类比

普通 call ≈ 跨进程调用另一个 Service,对方在自己的上下文里执行,改自己的数据。delegatecall 更像动态加载一段代码到你自己的进程里执行(想想 DexClassLoader 加载外部 dex 后在本进程跑)——它能读写你的字段。威力大,风险也大:加载错了代码,等于让别人改你家数据库。

用接口优雅地调用

日常你不会手写 call,而是用 interface 声明对方长啥样,让编译器帮你编码 calldata:

Solidity · 接口调用
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 订阅或查询这些日志,来感知"链上发生了什么"。

Solidity · 定义与触发事件
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。

Android 类比

事件 = 结构化日志 + 可订阅的广播,合起来像 Log.i 遇上 LiveData/Flow。合约 emit 就是打一条结构化日志,前端用 eth_getLogs(历史)或 WebSocket 订阅(实时)去"观察"它,像观察一个数据流。indexed 字段则像给日志加了可检索索引。第 20 章做 dApp 时,你就是靠监听事件来实时刷新界面的。

日志比 storage 便宜得多

一个重要的工程取舍:日志不能被合约读取(合约看不到自己或别人 emit 的日志),它只给链下用。但它比 storage 便宜一个数量级。所以模式是:需要合约逻辑用到的数据,存 storage;只给前端/索引器看的历史记录,发事件。比如"用户的当前余额"存 storage(合约要用它做校验),"每一笔转账历史"发事件(合约不需要回看,前端用它渲染流水)。

要点入账

两条主线:①合约间用 interface 调用实现可组合性;call 改被调方存储,delegatecall 借代码改自己存储(危险但强大);外部调用要防重入。②事件是合约对链下世界的唯一广播渠道,写进日志、比 storage 便宜,indexed 字段可高效过滤,前端靠它感知链上变化。下一章我们把工程化补上——用 Foundry 给这些合约写测试、跑部署,像你用 Gradle + JUnit 一样。

本章小结

◆ 返回目录