L2 开发的本质是把执行与结算分离:执行在 L2 跑,安全锚定在 L1。专业视角下,一条可用的 L2 由四个可独立验证的组件构成——定序器、批量提交者、证明者、桥。本文按这条链路拆解,并给出可运行的关键代码。
定序器的职责是给交易确定全序。早期 L2 都是单定序器,专业上的正确姿势是先把它做成可替换的接口,为后续去中心化留口子。
interface Sequencer {
submit(tx: L2Tx): Promise<TxReceipt>; // 返回 L2 预确认
batch(): Promise<Batch>; // 打包给 L1
}
class SingleSequencer implements Sequencer {
private queue: L2Tx[] = [];
private nonce = 0n;
async submit(tx: L2Tx): Promise<TxReceipt> {
if (tx.nonce !== this.nonce) throw new Error("nonce mismatch"); // 顺序强约束
this.nonce++;
this.queue.push(tx);
return { status: "preconfirmed", hash: keccak256(tx) };
}
async batch(): Promise<Batch> {
const txs = this.queue.splice(0); // 原子取走,避免重复打包
return { txs, stateRoot: await this.execute(txs) };
}
private async execute(_: L2Tx[]): Promise<Hash> { /* 调用 EVM 执行层 */ return "0x0"; }
}要点:nonce 强校验保证 L2 内交易有序;splice(0) 让打包与接收互不干扰。预确认(preconfirmed)必须向用户明确是软确认,L1 结算前不具备最终性。
batch 上 L1 时,数据可用性成本是 L2 的主要开销。4844 引入 blob 后,正确写法是走 blob 交易而非 calldata。
import { encodeAbiParameters } from "viem";
async function postBatch(wallet: WalletClient, batch: Batch) {
const compressed = compress(batch); // 先压缩,blob 空间有限
const blob = toBlob(compressed); // 4096 字段元素
const commitment = kzgBlobToCommitment(blob); // KZG 承诺
return wallet.sendTransaction({
to: BATCH_INBOX_ADDRESS,
data: encodeAbiParameters(
[{ type: "bytes32" }, { type: "bytes" }],
[commitment, compressed] // 承诺上链,数据进 blob
),
blobs: [blob],
kzg: { kzg },
maxFeePerBlobGas: 1_000_000_000n, // 独立于执行 gas 的定价
});
}要点:blob 数据 L1 不永久存储(约 18 天),必须另做 DA 备份或依赖 L1 合约事件留痕;maxFeePerBlobGas 与 maxFeePerGas 是两套市场,前端需分别估算,否则会卡单。
桥的安全核心是L1 侧不信任 L2 侧的任何声明。提款必须经过挑战期或有效性证明,这决定了"慢"是设计而非缺陷。
contract L2Bridge {
bytes32 public latestStateRoot;
uint256 public constant CHALLENGE_PERIOD = 7 days;
mapping(bytes32 => uint256) public rootProposedAt;
mapping(bytes32 => bool) public finalized;
event RootProposed(bytes32 root, uint256 at);
function proposeRoot(bytes32 root) external onlyProposer {
require(rootProposedAt[root] == 0, "already proposed");
rootProposedAt[root] = block.timestamp;
emit RootProposed(root, block.timestamp);
}
// 挑战期结束且无人成功挑战,才能确认
function finalize(bytes32 root) external {
uint256 t = rootProposedAt[root];
require(t != 0 && block.timestamp >= t + CHALLENGE_PERIOD, "not yet");
require(!finalized[root], "done");
finalized[root] = true;
latestStateRoot = root;
}
function withdraw(bytes32[] calldata proof, bytes32 leaf) external {
require(finalized[latestStateRoot], "root not final");
require(verifyMerkle(proof, latestStateRoot, leaf), "bad proof");
// 释放资产
}
}要点:finalize 与 proposeRoot 分离,让挑战者有窗口期提交欺诈证明;Merkle 证明绑定到已 finalize 的 root,防止用未确认状态提款。提款延迟 = 挑战期,这是 L2 安全预算的直接体现。
Optimistic 系要在挑战期内可被挑战,ZK 系要在每批附证明。开发时关键是把证明生成抽象成异步任务,不阻塞定序器。
from dataclasses import dataclass
from enum import Enum
class ProofType(Enum): FRAUD = 1; VALIDITY = 2
@dataclass
class ProofJob:
batch_id: int
state_root: str
proof_type: ProofType
def spawn_prover(job: ProofJob) -> None:
# ZK:生成 SNARK;Optimistic:注册挑战窗口
if job.proof_type is ProofType.VALIDITY:
proof = zk_prove(job.state_root) # 耗时操作,丢给队列
submit_proof(job.batch_id, proof) # 上 L1 验证合约
else:
register_challenge_window(job.batch_id, ttl=7 * 86400)要点:zk_prove 是分钟级操作,必须在独立 worker 跑;证明失败要有重试与告警,否则 L2 状态会停更。挑战窗口的 TTL 必须与桥合约的 CHALLENGE_PERIOD 严格一致,两边错位即安全漏洞。
L2 排障依赖三条指标:batch 提交延迟(超过 L1 出块周期即积压)、blob gas 花费、定序器队列长度。上线前逐项确认:提款延迟是否与挑战期匹配、blob 数据是否有 DA 备份、证明者失败是否有兜底、预确认与最终性文案是否对用户透明。
L2 开发的专业性,不在于会不会用 OP Stack 或 zkSync 的脚手架,而在于清楚每个组件的信任假设与失效模式:定序器是单点、blob 不永久、桥的慢是安全成本、证明异步且可能失败。把这四条写进架构与代码,才叫"开发 L2"而非"部署 L2"。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。