4、合约迁移前的审计与评估:如何评估现有合约的 Layer2 适配性,Gas 模型变化对合约逻辑的影响

说实话,很多团队在把合约往 Layer2 迁移时,最容易犯的错误就是——直接拿 L1 的代码往上怼。我见过太多次了,觉得「EVM 兼容嘛,部署上去就能跑」。结果呢?跑是能跑,但 Gas 花得莫名其妙,甚至逻辑都出了偏差。

今天我们就来聊聊,迁移前到底该怎么审计和评估。说白了,就是两件事:你的合约适不适合 Layer2? 以及 Gas 模型变了,你的逻辑扛不扛得住?

4.1 评估现有合约的 Layer2 适配性

我个人习惯,拿到一份待迁移的合约,先问三个问题:

  1. 它依赖了哪些 L1 特有的东西? 比如区块哈希、时间戳的精度、预编译合约。
  2. 它的存储模式在 L2 上是否高效? L2 的存储成本跟 L1 完全不是一个量级。
  3. 它有没有假设「最终性」是即时的? 这在 L2 上是个大坑。

4.1.1 检查对 L1 原语的依赖

Layer2 虽然兼容 EVM,但并不是 100% 复制。我遇到过最典型的例子,就是有人用了 blockhash(block.number - 1) 来做随机数。在 L1 上这勉强能用,但在 Optimistic Rollup 上,blockhash 的行为完全不同——它可能返回 0。

⚠️ 注意: 在 Arbitrum 和 Optimism 上,blockhash 只对最近 256 个区块有效,而且某些情况下会返回空值。千万别拿它做核心逻辑。

还有 block.timestamp。L2 的区块时间戳往往由 sequencer 控制,精度可能跟 L1 不一样。我见过一个 DeFi 项目,在 L1 上用时间戳做清算阈值,迁移到 L2 后,清算时机全乱了。

所以我的建议是:把合约里所有跟 blocktx 相关的全局变量列出来,一个一个对照目标 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 后,我让他们加了一个 「最终性等待期」 的逻辑。说白了,就是等挑战期过了,才真正执行某些敏感操作。

💡 小技巧: 如果你的合约涉及跨链消息或桥接,一定要关注 L2 的「最终性确认」机制。在代码里显式地等待确认,而不是假设它已经完成。

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;
}
📌 重点: 在 L2 上,减少 calldata 大小 比减少计算量更重要。因为数据可用性成本是 Gas 的大头。

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 我的迁移前检查清单

说了这么多,最后给大家一个实用的清单。每次迁移前,我都会拿着这个清单过一遍:

  1. 检查所有 block.*tx.* 的使用——确认在目标 L2 上的行为是否一致。
  2. 评估存储模式——高频数据是否应该上链?存储布局是否需要优化?
  3. 分析 Gas 构成——用 Gas 分析工具跑一遍,看看 calldata 成本占比。
  4. 测试最终性假设——合约里有没有「即时确认」的逻辑?加上等待期。
  5. 检查循环和批量操作——确保不会超过 L2 的区块 Gas 上限。
  6. 重新计算 Gas 预算——L2 的 Gas 价格波动可能跟 L1 不同,调整你的 Gas 策略。

嗯,差不多就是这些。其实说白了,迁移前的审计和评估,就是一次「重新认识你的合约」的过程。别把它当成负担,当成一次优化机会。我每次做完迁移评估,都会发现一些在 L1 上没注意到的设计缺陷——这本身就是收获。

下一章我们会聊聊具体的迁移工具和流程,包括怎么用 Foundry 做跨链测试。到时候见。