4、合约迁移前的审计与评估:如何评估现有合约的 Layer2 适配性,Gas 模型变化对合约逻辑的影响
说实话,很多团队在把合约往 Layer2 迁移时,最容易犯的错误就是——直接拿 L1 的代码往上怼。我见过太多次了,觉得「EVM 兼容嘛,部署上去就能跑」。结果呢?跑是能跑,但 Gas 花得莫名其妙,甚至逻辑都出了偏差。
今天我们就来聊聊,迁移前到底该怎么审计和评估。说白了,就是两件事:你的合约适不适合 Layer2? 以及 Gas 模型变了,你的逻辑扛不扛得住?
4.1 评估现有合约的 Layer2 适配性
我个人习惯,拿到一份待迁移的合约,先问三个问题:
- 它依赖了哪些 L1 特有的东西? 比如区块哈希、时间戳的精度、预编译合约。
- 它的存储模式在 L2 上是否高效? L2 的存储成本跟 L1 完全不是一个量级。
- 它有没有假设「最终性」是即时的? 这在 L2 上是个大坑。
4.1.1 检查对 L1 原语的依赖
Layer2 虽然兼容 EVM,但并不是 100% 复制。我遇到过最典型的例子,就是有人用了 blockhash(block.number - 1) 来做随机数。在 L1 上这勉强能用,但在 Optimistic Rollup 上,blockhash 的行为完全不同——它可能返回 0。
blockhash 只对最近 256 个区块有效,而且某些情况下会返回空值。千万别拿它做核心逻辑。
还有 block.timestamp。L2 的区块时间戳往往由 sequencer 控制,精度可能跟 L1 不一样。我见过一个 DeFi 项目,在 L1 上用时间戳做清算阈值,迁移到 L2 后,清算时机全乱了。
所以我的建议是:把合约里所有跟 block、tx 相关的全局变量列出来,一个一个对照目标 L2 的文档。别偷懒,这一步省不了。
4.1.2 存储模式的适配性
嗯,这里要注意。L2 的 Gas 模型跟 L1 最大的区别,就是 存储成本。在 L1 上,SSTORE 操作很贵,所以大家拼命压缩存储。但在 L2 上,尤其是 ZK Rollup,计算成本相对更高,存储反而没那么贵了。
举个例子。我之前审计过一个 NFT 项目,他们在 L1 上把所有元数据都放在 IPFS,链上只存一个哈希。这个设计在 L1 上没问题,但迁移到 zkSync 后,每次查询都要跨链拿数据,用户体验极差。
我当时的建议是:把高频读取的数据直接存到链上。虽然存储成本高了点,但整体 Gas 反而降了,因为省掉了大量的跨链调用。
| 存储策略 | L1 适配性 | L2 适配性 | 我的建议 |
|---|---|---|---|
| 链上只存哈希,数据放 IPFS | ✅ 推荐 | ⚠️ 视情况 | 高频数据建议上链 |
| 大量使用 mapping 存储 | ✅ 可以 | ✅ 可以 | 注意 slot 布局,减少碰撞 |
| 频繁读写同一个 slot | ⚠️ 成本高 | ✅ 成本低 | L2 上可以更「奢侈」 |
| 使用代理模式(UUPS/Transparent) | ✅ 推荐 | ✅ 推荐 | 但要注意 L2 的部署成本 |
4.1.3 最终性假设
这是个大坑。L1 上,一笔交易一旦被确认,基本就不可逆了。但 L2 不一样——尤其是 Optimistic Rollup,有 7 天的挑战期。你想想看,如果你的合约假设「交易一旦写入就立即生效」,那在 L2 上可能会出问题。
我曾经帮一个跨链桥做迁移评估,他们的合约在 L1 上依赖「即时最终性」来做快速清算。迁移到 Arbitrum 后,我让他们加了一个 「最终性等待期」 的逻辑。说白了,就是等挑战期过了,才真正执行某些敏感操作。
4.2 Gas 模型变化对合约逻辑的影响
好,适配性看完了,我们聊聊 Gas。这是迁移中最容易被忽视的部分。很多人觉得「L2 Gas 便宜,所以不用优化了」。大错特错。
Gas 模型变了,你的合约逻辑可能也要跟着变。为什么?因为 Gas 的计价方式不同了。
4.2.1 L1 vs L2 的 Gas 构成
在 L1 上,Gas 主要花在计算和存储上。但在 L2 上,尤其是 Rollup,Gas 分为两部分:执行 Gas 和 数据可用性 Gas(也就是 calldata 的成本)。
说白了,你在 L2 上发一笔交易,不仅要付「算力钱」,还要付「把数据发回 L1 的钱」。这个数据可用性成本,在某些 L2 上可能占总 Gas 的 80% 以上。
我见过一个项目,他们在 L1 上把大量数据放在事件(Event)里,因为事件便宜。但迁移到 L2 后,事件的成本反而高了——因为事件数据也要发回 L1。他们不得不重构日志系统。
4.2.2 代码层面的 Gas 优化调整
既然 Gas 模型变了,那我们的优化策略也得变。我个人总结了几个关键点:
- 减少 calldata 大小:能压缩的就压缩。比如用
uint128代替uint256,如果数值范围允许的话。 - 批量操作更划算:在 L2 上,一次批量提交的边际成本很低。我建议把多个小交易合并成一个。
- 存储读写策略调整:L2 上 SLOAD 和 SSTORE 的成本比例跟 L1 不同。具体来说,SLOAD 相对更贵了,所以能缓存就缓存。
// ❌ L1 风格:频繁读取存储
function getBalance(address user) public view returns (uint256) {
return balances[user]; // 每次都要 SLOAD
}
// ✅ L2 优化:批量读取,减少 SLOAD 次数
function getBalances(address[] calldata users) public view returns (uint256[] memory) {
uint256[] memory result = new uint256[](users.length);
for (uint i = 0; i < users.length; i++) {
result[i] = balances[users[i]]; // 虽然还是 SLOAD,但一次调用查多个
}
return result;
}
4.2.3 Gas 上限与区块限制
还有一个容易被忽略的点:L2 的区块 Gas 上限可能比 L1 低很多。比如某些 L2 的单个区块 Gas 上限只有几百万,而 L1 是 3000 万。如果你的合约里有一个循环,在 L1 上能跑完,在 L2 上可能直接 out of gas。
我记得有一次,一个 DEX 项目迁移到 L2,他们的清算函数里有一个 for 循环,最多能处理 100 个用户。在 L1 上没问题,但在 L2 上,跑到第 50 个就爆了。最后我们不得不改成「分批清算」的模式。
4.3 我的迁移前检查清单
说了这么多,最后给大家一个实用的清单。每次迁移前,我都会拿着这个清单过一遍:
- 检查所有
block.*和tx.*的使用——确认在目标 L2 上的行为是否一致。 - 评估存储模式——高频数据是否应该上链?存储布局是否需要优化?
- 分析 Gas 构成——用 Gas 分析工具跑一遍,看看 calldata 成本占比。
- 测试最终性假设——合约里有没有「即时确认」的逻辑?加上等待期。
- 检查循环和批量操作——确保不会超过 L2 的区块 Gas 上限。
- 重新计算 Gas 预算——L2 的 Gas 价格波动可能跟 L1 不同,调整你的 Gas 策略。
嗯,差不多就是这些。其实说白了,迁移前的审计和评估,就是一次「重新认识你的合约」的过程。别把它当成负担,当成一次优化机会。我每次做完迁移评估,都会发现一些在 L1 上没注意到的设计缺陷——这本身就是收获。
下一章我们会聊聊具体的迁移工具和流程,包括怎么用 Foundry 做跨链测试。到时候见。