以太坊主网的 TPS 长期徘徊在十几笔,单笔转账的 gas 费在拥堵时能飙到几十美元。对高频交互类应用(DEX、链游、社交、支付)来说,这不是"体验差"的问题,而是"能不能做"的问题。
Layer2 的核心思路是把执行搬到链下或独立的执行层,只把压缩后的状态根或交易数据提交回 L1 获取安全保证。EIP-4844(blob 交易)落地之后,L2 向 L1 提交数据的成本下降了一个数量级,主流 Rollup 的单笔交易费用已经降到几美分甚至更低。
对开发者而言,Layer2 不是"另一条链"——它保留了 EVM 语义,绝大多数 Solidity 合约可以直接部署。但它也不是完全等价于 L1,有大量细节差异会在上线后咬你一口。这篇文章就按"选型 → 部署 → 踩坑 → 跨链 → 运维"的顺序讲清楚。
按"数据可用性 + 证明方式"两个维度,主流方案可以分成四类:
类型 | 代表 | 数据位置 | 有效性证明 | 提现延迟 |
|---|---|---|---|---|
Optimistic Rollup | Arbitrum One、OP Mainnet、Base | L1(calldata/blob) | 欺诈证明(挑战期) | 约 7 天 |
ZK Rollup | zkSync Era、Linea、Scroll、Polygon zkEVM | L1 | 零知识证明 | 分钟到小时级 |
Validium | Starknet(部分模式)、各类 zk 应用链 | 链下 DA 委员会 | 零知识证明 | 较快 |
状态通道 / Plasma | 早期方案 | 链下 | 无/弱 | 依赖对手方 |
对绝大多数业务,选择实际落在 Optimistic Rollup 和 ZK Rollup 之间。
选 Optimistic 的理由:EVM 兼容度高(OP Stack 几乎是逐字节兼容,Arbitrum 有极少数差异),工具链成熟,Gas 便宜,生态和流动性最好。缺点是提现要等挑战期。
选 ZK 的理由:提现快(适合做跨链资金桥、CEX 出入金),最终性明确,长期看是终局方案。缺点是 EVM 兼容性仍有"等价性"差异(不同操作码的 gas 计量、部分预编译行为不同),编译器和工具链的坑更多。
一个务实的判断:如果你的应用重度依赖 DeFi 可组合性,选 Arbitrum / Base / OP;如果你要做支付、清算、跨链结算这类对最终性敏感的场景,或者需要给用户快速提现体验,选 zkSync Era / Linea。
好消息是,你不需要学新语言。Solidity + Foundry/Hardhat 就是主力。区别只在配置:
# foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
[rpc_endpoints]
l1 = "${L1_RPC_URL}"
arb = "https://arb1.arbitrum.io/rpc"
base = "https://mainnet.base.org"
op = "https://mainnet.optimism.io"
zksync = "https://mainnet.era.zksync.io"部署命令和 L1 完全一致:forge script script/Deploy.s.sol --rpc-url base --broadcast。链 ID 分别对应 Arbitrum 42161、Base 8453、OP 10、zkSync Era 324。
需要注意的是 L2 上的预部署合约(predeploy)。它们固定在某些地址上,由协议本身维护,你在编写合约时可以直接调用:
L1Block 在 0x4200000000000000000000000000000000000015,GasPriceOracle 在 0x420000000000000000000000000000000000000FArbSys 在 0x0000000000000000000000000000000000000064,ArbInfo、ArbGasInfo 各有固定地址这些合约是 L2 特有能力(读取 L1 状态、计算 L1 数据费)的唯一入口。
L2 上最容易出错的地方,是时间与区块语义。block.number 在 L2 上是 L2 自己的块高,增长极快(Arbitrum 约 0.25 秒一个块,一天几十万块),完全不能用来做"按天"的逻辑。
下面这个合约演示如何正确读取 L1 时间:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IL1Block {
function number() external view returns (uint64);
function timestamp() external view returns (uint64);
}
contract L1Aware {
// OP Stack 预部署地址;Arbitrum 需换成 ArbSys 的 arbBlockNumber()
IL1Block constant L1_BLOCK =
IL1Block(0x4200000000000000000000000000000000000015);
/// @notice 用 L1 时间做业务判断,避免 L2 出块速度带来的偏差
function isL1DayPassed(uint64 lastL1Ts) public view returns (bool) {
return L1_BLOCK.timestamp() >= lastL1Ts + 1 days;
}
function l1BlockNumber() external view returns (uint64) {
return L1_BLOCK.number();
}
}关键点:任何和"真实世界时间""区块高度""随机性"相关的逻辑,都要明确用的是哪一层的时间源。 L2 的 block.timestamp 由排序器(sequencer)决定,虽然协议有约束,但精度和含义都和 L1 不同。
L2 上一笔交易的费用 = L2 执行费 + L1 数据费。后者是 Rollup 把交易数据打包上链的成本,按字节计费。
在 OP Stack 上可以通过 GasPriceOracle.getL1Fee(bytes) 估算;Arbitrum 用 ArbGasInfo.getCurrentTxL1GasFees()。如果你写的是钱包、聚合器、或者需要给用户预估费用的前端,必须把 L1 数据费算进去,否则用户会看到"余额足够但交易失败"。
// viem 示例:拿到 L1 数据费
import { createPublicClient, http, parseAbi } from 'viem';
import { optimism } from 'viem/chains';
const client = createPublicClient({ chain: optimism, transport: http() });
const oracle = '0x420000000000000000000000000000000000000F' as const;
const abi = parseAbi([
'function getL1Fee(bytes calldata _data) view returns (uint256)',
]);
const l1Fee = await client.readContract({
address: oracle,
abi,
functionName: 'getL1Fee',
args: [serializedTx],
});优化方向:L1 数据费按字节算,所以 calldata 越短越省钱。用更紧凑的编码、减少事件字段、用 bytes32 代替字符串,能带来实打实的成本下降。EIP-4844 之后 blob 数据更便宜,但"少写字节"依然是第一原则。
tx.origin 和 msg.sender 的账户抽象差异在 zkSync Era 上,EOA 本身有账户抽象能力,msg.sender 可能是合约地址。任何依赖 tx.origin == msg.sender 判断"是不是人类用户"的逻辑都会失效。标准建议是:永远不要用 tx.origin 做鉴权,这在 L1 上也是安全最佳实践,但在 L2 上是硬性要求。
同样的 deployer + salt,在 L1 和 L2 上算出来的地址不同,因为链 ID 和 deployer 的 nonce 独立。跨链部署同一套合约、期望地址对齐的项目(比如跨链镜像合约、确定性钱包)必须显式处理这一点,通常做法是用一个固定的 deployer 合约(如确定性部署代理)在两侧分别部署。
Optimistic Rollup 的 L1 提现要等挑战期(约 7 天)。很多团队在上线后才发现"用户提现要等一周"是个致命体验问题。
解决方案有三条:
不要把"7 天"当成技术细节塞进文档角落,它直接决定了你的资金产品能不能成立。
L2 的"最终性"是分层的:排序器确认(秒级)→ L2 区块确认(分钟级)→ L1 最终性(十几分钟到数小时,取决于 Rollup 类型)。你的后端索引器如果只监听 L2 的 eth_getLogs,在极端情况下(排序器重组、故障证明推翻)需要能回滚状态。
实践建议:索引器记录 (l2BlockNumber, l1BatchIndex) 双维度游标,对高价值操作(提现、大额结算)等 L1 批次确认后再置为终态。
L2 的区块浏览器(Arbiscan、Basescan、OP Explorer)大多兼容 Etherscan API,但 debug_traceTransaction 的支持程度不一。zkSync Era 的 trace 格式和 geth 不同,Foundry 的 forge test 在 zkSync 上需要专门编译(--zksync),部分 cheatcode 不可用。在项目早期就把 CI 跑通在目标 L2 上,别等到上线前一周才发现测试跑不起来。
跨链是 L2 开发里绕不开的部分。这里给一个 OP Stack 原生跨链桥 L1CrossDomainMessenger 的最小封装思路(只示意调用方式,不是完整生产代码):
// L2 侧:向 L1 发消息
interface IL2CrossDomainMessenger {
function sendMessage(
address _target,
bytes calldata _message,
uint32 _minGasLimit
) external payable;
}
contract L2Sender {
IL2CrossDomainMessenger constant MESSENGER =
IL2CrossDomainMessenger(0x4200000000000000000000000000000000000007);
event Sent(address indexed target, bytes payload);
function sendToL1(address target, bytes calldata payload) external payable {
MESSENGER.sendMessage{value: msg.value}(target, payload, 200_000);
emit Sent(target, payload);
}
}对应的 L1 接收方必须校验调用者:
contract L1Receiver {
address constant MESSENGER = 0x25ace71c97B33Cc4729CF772ae268934F7ab5fA1;
address constant L2_SENDER = 0x...; // L2 侧的 L2Sender 地址
function onReceive(bytes calldata payload) external {
require(msg.sender == MESSENGER, "not messenger");
// 关键:校验 L2 侧的 xDomainMessageSender
require(
IL1CrossDomainMessenger(MESSENGER).xDomainMessageSender() == L2_SENDER,
"not authorized sender"
);
// ... 业务逻辑
}
}最容易被忽略的一行是 xDomainMessageSender() 校验。很多人只校验 msg.sender == messenger 就放行,这意味着任何人都能通过官方桥向你的合约发消息,直接把资金抽走。这是跨链合约审计中最高频的高危漏洞之一。
一个可落地的流程建议:
forge test --fork-url $ARB_RPC),重点验证预部署合约交互、Gas 估算、外部协议调用。你的场景 | 推荐 |
|---|---|
DeFi / NFT / 通用 dApp,追求生态和流动性 | Arbitrum One、Base |
需要极强 EVM 兼容、自建链(应用链) | OP Stack(可用 Superchain 生态) |
支付、清算、需要快速最终性 | zkSync Era、Linea |
需要自定义 DA、极致低成本 | Validium / 应用链(如用 OP Stack + 替代 DA) |
行动清单:
block.number、block.timestamp、tx.origin 全部审一遍,确认语义xDomainMessageSender 级别的校验Layer2 开发的本质,是在"几乎和 L1 一样"的表象下,处理好那 5% 的差异。剩下的 95% 你已经会了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。