资讯动态

从零手写最小Rollup:Solidity合约与排序器实现

发布时间:2026/9/14 8:59:48 来源:尧图企业网站定制
做以太坊合约开发和链上应用的人这几年应该都绕不开同一个词Layer 2。尤其Rollup已经从“方案之争”走到了“生产环境大规模使用”的阶段几乎成了扩容路线的默认答案。但如果你跟我一样平时用惯了RPC、钱包和Solidity面对“Rollup到底怎么实现”这个问题时很容易卡在概念层面——知道它把计算搬到链下却不知道链上合约到底存了什么、排序器做了什么、一个批次是怎么从交易变成状态根的。这篇文章不打算复述白皮书而是带着你把一个最小可运行的Rollup Demo从头写出来用合约和代码把机制讲透适合已经有Solidity和JavaScript基础、想往Layer 2方向深挖的开发者。1. 为什么是Rollup执行下移数据上链1.1 链上瓶颈不只在执行速度很多人说以太坊慢第一反应是“执行太贵”。这个说法只说对了一半。以太坊每个区块有Gas上限所有交易和合约调用都得抢这块空间单个区块能装下的计算量是硬顶。普通ERC-20转账需要四五万Gas复杂一点的DeFi交互能到几十万。如果某个区块塞满了后面的交易就只能排队等下一个区块这种竞争抬高了Gas价格也限制了整个网络的吞吐量。但真正可怕的是节点验证成本。每个全节点都要同步并重放所有交易节点硬件跟不上链就不能往上加复杂计算。Rollup的思路就是绕开这个限制把“执行”挪到链下主链只保留一笔压缩后的数据和一个最终状态根谁有异议谁来挑战。主链不用重放每一笔交易自然就腾出了空间。1.2 Optimistic与ZK信任假设换扩容空间Rollup内部又分成两大路线。Optimistic Rollup默认交易都是对的挑战期内如果有人能证明某笔交易错了这一批就作废ZK Rollup则直接用密码学证明让链上一步校验通过整批交易的正确性。两者都是在链下做完脏活累活链上只做轻量验证但信任模型完全不同。维度Optimistic RollupZK Rollup正确性保证依赖挑战者提交欺诈证明依赖零知识证明的数学有效性提款周期通常需要等待几天挑战期证明附上后可快速提款链上验证成本较低只需状态根对比单笔证明验证成本较高但压缩能力强技术复杂度欺诈证明设计复杂证明生成电路复杂代表人物示例Optimism、ArbitrumzkSync、Scroll我在开发中使用感受最明显的是Optimistic系上手快合约逻辑很接近传统Solidity开发ZK系虽然最终用户体感和成本都好但生成证明这条链路对开发者有更高门槛不是搭个环境就算完事。1.3 压缩链路calldata才是决定TPS的变量很多人以为Rollup省Gas是靠“不执行”其实执行根本不是主链关心的主链只关心数据。Rollup把一批交易打包发送到L1合约时这些打包数据会以calldata形式永久留在区块里。L1上的EVM调用不需要存储这些交易但它们必须能被任何人读取这是数据可用性的基础。这里有个容易被忽略的数字以太坊calldata里非零字节要16 Gas零字节要4 Gas。一个常规ERC-20转账在主链上需要大量签名和地址字段但Rollup里可以把地址索引压缩成几个字节nonce、金额也都用紧凑编码。这样一笔转账的数据可能从几百字节压到几十字节单块能承载的交易数量自然就上来了。这也是为什么很多Rollup在讨论“优化”时先看数据压缩而不是单纯堆执行引擎。真正决定TPS上限的是你能把交易数据压得多小以及L1区块愿意给calldata留多少空间。2. 链上边界一个最小Rollup的Solidity合约2.1 合约职责提交、挑战、终局看Rollup的第一件事不是排序器而是链上合约。链上合约是Rollup的“锚点”所有参与者都以它为准。不管链下执行引擎做得再花哨最后都是通过合约提交批次、存状态根、处理挑战和最终确认。链上合约至少要管三件事提交批次排序器把一批交易的状态转换结果提交上来包含交易前状态根、交易后状态根、以及压缩交易数据的哈希。挑战与防伪如果某个批次有问题挑战者需要提交证据证明某个状态转换无效。一旦证实批次作废。最终确认超过挑战窗口后批次可以终局化新的状态根被写入合约。我一开始以为Rollup合约会很大但真正拆开看核心逻辑很少难点全在数据怎么表达、证明怎么验证。下面这个Demo就是围绕这三件事设计的。2.2 链上代码实现这里我给一个简化但可运行的合约。它不包含完整欺诈证明只做状态机闭环方便你把整个生命周期跑通。// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SimpleRollup { enum BatchStatus { Pending, Finalized, Rejected } struct Batch { bytes32 preStateRoot; bytes32 postStateRoot; bytes32 dataHash; address proposer; uint256 submittedAt; BatchStatus status; } // 当前已确认的状态根 bytes32 public stateRoot; address public operator; uint256 public immutable challengeWindow; mapping(uint256 Batch) private _batches; uint256 public batchCount; event BatchSubmitted( uint256 indexed batchId, bytes32 preStateRoot, bytes32 postStateRoot, bytes32 dataHash ); event BatchFinalized(uint256 indexed batchId); event BatchRejected(uint256 indexed batchId); modifier onlyOperator() { require(msg.sender operator, SimpleRollup: not operator); _; } constructor(bytes32 _initialStateRoot, uint256 _challengeWindow) { stateRoot _initialStateRoot; operator msg.sender; challengeWindow _challengeWindow; } function submitBatch( bytes32 _preStateRoot, bytes32 _postStateRoot, bytes32 _dataHash ) external onlyOperator { require(_preStateRoot stateRoot, SimpleRollup: pre state mismatch); _batches[batchCount] Batch({ preStateRoot: _preStateRoot, postStateRoot: _postStateRoot, dataHash: _dataHash, proposer: msg.sender, submittedAt: block.timestamp, status: BatchStatus.Pending }); emit BatchSubmitted(batchCount, _preStateRoot, _postStateRoot, _dataHash); batchCount; } function finalizeBatch(uint256 _batchId) external { Batch storage batch _batches[_batchId]; require(batch.status BatchStatus.Pending, SimpleRollup: not pending); require( block.timestamp batch.submittedAt challengeWindow, SimpleRollup: challenge window not over ); batch.status BatchStatus.Finalized; stateRoot batch.postStateRoot; emit BatchFinalized(_batchId); } function challengeBatch( uint256 _batchId, bool _proofValid ) external { require(_proofValid, SimpleRollup: proof not valid); Batch storage batch _batches[_batchId]; require(batch.status BatchStatus.Pending, SimpleRollup: not pending); // 生产环境里这里会是一个真正的欺诈证明验证流程。 // 挑战者需要提供默克尔证明和交易见证数据合约对违规交易做重放校验 // 校验失败则批次作废、挑战者获得奖励。这里用 bool 替代完整流程 // 方便跑通状态流转。 batch.status BatchStatus.Rejected; emit BatchRejected(_batchId); } }这个合约里的submitBatch有个关键约束_preStateRoot必须等于当前stateRoot。这意味着批次必须串行提交前一批确认后下一批才能用新的状态根继续。真实Rollup里也有这种限制只是排序器会维护一个更复杂的待处理队列。2.3 为什么我把欺诈证明只留接口可能有读者会问第2.2节里的challengeBatch太简陋了这不等于没实现防御吗确实这个Demo为了可读性刻意把欺诈证明去掉只用bool _proofValid来模拟验证结果。真实的生产级Rollup里这一步是整个安全模型的核心也是最难写的部分。欺诈证明为什么难写因为它要求合约能对某笔交易做“确定性重放”也就是把链下执行引擎里的某个操作用Solidity再原样实现一遍。交易格式、签名算法、状态变更规则全都要对齐任何一处不一致都会导致挑战结果错误。我在调研时看过一些测试网络上的早期实现经常出现“链下执行成功、链上重放失败”的尴尬问题最后发现是整数溢出或编码顺序不一致。想从Demo走向生产欺诈证明这一关躲不掉建议先从单笔ERC-20转账的欺诈证明开始练手。3. 离线执行引擎排序器与默克尔状态的管理3.1 状态设计和叶子节点格式链上合约只存一个状态根那链下的状态就必须能被“压”成一个根。最通用的办法是默克尔树。账户地址、nonce、余额作为叶子节点整棵树递归哈希得到根。这样链上不需要保存所有账户任何一笔交易涉及哪个账户只需要给出默克尔证明就能验证它在某棵树里的存在。我先给初始账户一个简单结构每个账户对应叶子节点为keccak256(abi.encode(address, nonce, balance))。nonce必须包含进去否则同一笔交易被重放时账户余额和nonce都不会被识破状态根就会出大问题。3.2 applyTx的签名验证与状态变更执行引擎的核心是applyTx方法。排序器收到用户签名好的交易先验签再变更状态。如果验签失败、nonce不匹配、余额不足整个批次就应该被拒。下面这段代码是用ethers.js辅助、实现最小执行引擎的示意const { ethers } require(ethers); const { MerkleTree } require(merkletreejs); const { defaultAbiCoder, keccak256 } ethers.utils; class Account { constructor(address, balance) { this.address address.toLowerCase(); this.nonce 0; this.balance balance; } } function accountLeaf(account) { return keccak256( defaultAbiCoder.encode( [address, uint256, uint256], [account.address, account.nonce, account.balance] ) ); } class RollupEngine { constructor(initialBalances) { this.accounts new Map(); for (const [address, balance] of Object.entries(initialBalances)) { this.accounts.set(address.toLowerCase(), new Account(address, balance)); } } stateRoot() { const leaves Array.from(this.accounts.values()) .sort((a, b) a.address.localeCompare(b.address)) .map(accountLeaf); const tree new MerkleTree(leaves, keccak256, { sortPairs: true }); return tree.getRoot(); } applyTx(tx) { const digest keccak256( defaultAbiCoder.encode( [uint256, address, address, uint256, uint256], [tx.nonce, tx.from, tx.to, tx.amount, tx.chainId] ) ); const signer ethers.utils.recoverAddress(digest, tx.signature); if (signer.toLowerCase() ! tx.from.toLowerCase()) { throw new Error(签名不匹配); } const sender this.accounts.get(tx.from.toLowerCase()); const recipient this.accounts.get(tx.to.toLowerCase()); if (!sender || !recipient) { throw new Error(账户不存在); } if (sender.nonce ! tx.nonce) { throw new Error(nonce 不合法); } if (sender.balance tx.amount) { throw new Error(余额不足); } sender.balance - tx.amount; sender.nonce 1; recipient.balance tx.amount; } }注意我构造digest时的字段顺序必须和用户签名交易时的字段顺序完全一致。这里我用了[nonce, from, to, amount, chainId]那么前端用户签名时也必须按这个顺序编码否则签出的数据在recoverAddress时就不是同一个摘要。3.3 批次数据封装与内存压缩排序器收集到若干笔合法交易后依次执行applyTx得到新的状态根。之后需要把“这批交易本身”也提交到链上合约。在这个Demo里我用一个dataHash代表压缩后的交易数据但真实Rollup必须把压缩交易数据发布到calldata否则别人无法校验状态转换是否合理。压缩思路其实不复杂核心是让交易字段尽量短。地址不用完整放可以先给账户列表排个序用索引替代地址nonce和金额也可以用定长的uint32、uint128等紧凑编码。下面是一个手工模拟压缩的片段function encodeBatch(engine, txs) { const addresses Array.from(engine.accounts.keys()).sort(); const addressIndex new Map(addresses.map((addr, idx) [addr, idx])); return txs.map((tx) { const fromIndex addressIndex.get(tx.from.toLowerCase()); const toIndex addressIndex.get(tx.to.toLowerCase()); return { nonce: tx.nonce, fromIndex, toIndex, amount: tx.amount, }; }); }链下拿到这个压缩数组再序列化成一串字节交给合约。这样虽然看起来不像主链上那种address字段直观但能大幅降低calldata成本也正是Rollup能提高吞吐量的重要原因之一。4. 端到端跑通Hardhat环境部署并提交一个批次4.1 项目初始化与依赖组合我建议直接新建一个Hardhat项目来跑这个Demo。手把手的流程如下mkdir simple-rollup cd simple-rollup npm init -y npm install --save-dev hardhat nomicfoundation/hardhat-ethers ethers5.7.2 merkletreejs npx hardhat initethers5.7.2和merkletreejs是必须的。merkletreejs用来在本地计算默克尔根ethers负责签名和账户操作。新版ethers v6的API差异较大我这里用的是v5如果你已经装了v6注意把defaultAbiCoder.encode这些老API替换成v6写法。把第2.2节的合约保存到contracts/SimpleRollup.sol然后写部署脚本// scripts/01_deploy.js const { ethers } require(hardhat); async function main() { const [deployer] await ethers.getSigners(); console.log(Deployer:, deployer.address); // 初始状态根的生成放在后续脚本里做这里先占位 const emptyRoot ethers.constants.HashZero; const challengeWindow 60; const SimpleRollup await ethers.getContractFactory(SimpleRollup); const rollup await SimpleRollup.deploy(emptyRoot, challengeWindow); await rollup.deployed(); console.log(SimpleRollup deployed to:, rollup.address); } main().catch(console.error);4.2 将离线批次推送到链上接着写一个排序器模拟脚本先生成两个账户和一笔转账交易签好名再让引擎执行最后把计算结果提交到合约。// scripts/02_operator.js const { ethers } require(hardhat); const { Wallet } ethers; const { defaultAbiCoder, keccak256 } ethers.utils; const { RollupEngine, accountLeaf } require(../lib/engine); async function main() { const [deployer] await ethers.getSigners(); const rollup await ethers.getContractAt( SimpleRollup, process.env.ROLLUP_ADDRESS ); const alice Wallet.createRandom(); const bob Wallet.createRandom(); const engine new RollupEngine({ [alice.address]: 1000, [bob.address]: 200, }); // 第一次提交空状态到当前状态作为链上初始确认 const preStateRoot ethers.constants.HashZero; const postStateRoot engine.stateRoot(); const emptyDataHash keccak256(defaultAbiCoder.encode([string], [])); const submitEmptyTx await rollup.submitBatch( preStateRoot, postStateRoot, emptyDataHash ); await submitEmptyTx.wait(); console.log(Empty batch submitted, root:, postStateRoot); const tx { nonce: 0, from: alice.address, to: bob.address, amount: 300, chainId: (await ethers.provider.getNetwork()).chainId, }; const digest keccak256( defaultAbiCoder.encode( [uint256, address, address, uint256, uint256], [tx.nonce, tx.from, tx.to, tx.amount, tx.chainId] ) ); tx.signature alice.signingKey.sign(digest).serialized; const beforeRoot engine.stateRoot(); engine.applyTx(tx); const afterRoot engine.stateRoot(); const dataHash keccak256( defaultAbiCoder.encode( [tuple(uint256 nonce,uint256 fromIndex,uint256 toIndex,uint256 amount)[]], [[ { nonce: 0, fromIndex: 0, toIndex: 1, amount: 300, }, ]] ) ); const submitTx await rollup.submitBatch(beforeRoot, afterRoot, dataHash); await submitTx.wait(); console.log(Batch submitted); console.log(beforeRoot:, beforeRoot); console.log(afterRoot:, afterRoot); } main().catch(console.error);这里我为了演示把转账压缩数据写死了。实际生产里引擎应该从交易列表和账户索引动态生成这段数据但从0到1理解一个批次怎么上链这样已经足够。4.3 实操中的Gas陷阱和常见坑我在跑这类Demo时踩过几个坑这里集中说一下。第一提交批次的preStateRoot和合约里当前stateRoot对不上是最常见的报错。合约定序排列非常严格前一个批次还没终局化新的批次就不能用同一个前序根提交。你如果想让多个批次连续上链必须等前一个批次finalizeBatch调用成功后再提交。第二注意Gas。把大量压缩数据写入calldata会消耗大量Gas测试网络无所谓但主网上的成本是真实存在的。所以生产Rollup对calldata压缩的要求非常高很多团队甚至会为了几个字节优化Calldata布局。第三签名摘要的字段顺序必须保持一致否则签名验证会失败。如果你在applyTx里把from放在nonce前面那么在签名时也必须把from放在nonce前面。序列化顺序不一致会浪费很长时间排查。5. 从Demo到生产Rollup的进化经验与风险清单5.1 dataHash与完整calldata发布的数据可用性博弈很多刚接触Rollup的人有个误解只要链上存了状态根用户资产就安全了。其实安全的前提是“数据可得”。如果排序器只是提交一个状态根而不把交易数据本身发到L1那么用户可以信任这个新根但无法验证这个根是怎么来的。一旦排序器作恶把所有人余额偷偷改掉受害者连证明自己资产被改的证据都拿不到因为链上根本没有原始交易。这就是为什么生产Rollup不能只提交dataHash而要把压缩后的交易数据作为calldata发布到L1。Calldata不会永久占用合约存储但会被所有全节点记录属于以太坊永久历史的一部分。所以在真实系统里排序器的主要Gas开销其实来自发布这批数据而不是调用合约本身。我自己的体会是理解这一点再回头看各种Rollup优化方案就会明白它们为什么连“零字节和十六字节的差异”都抠得那么细。5.2 去中心化排序器、MEV和用户保护的取舍还有一个绕不开的问题排序器如果只有一个用户交易就完全在它手里。它可以选择先处理自己的交易也可以选择不打包某个用户的交易。生产Rollup目前普遍的做法是先中心化再逐步过渡到基于准入机制的排序器集合。在安全和公平性上排序器通常要承诺一些行为比如“同一时间只打包一个序列”“交易费用透明公开”。但这只是协议约束不改变它拥有交易顺序控制权的事实。想真正限制排序器的MEV能力需要引入更复杂的加密交易池或者共享排序器协议。这个领域到现在还在快速演进现在写一个Demo级排序器时至少要把“交易进入排序器后排序器能不能看得到交易内容”这个特性考虑进去否则后面想加隐私或公平性功能会特别痛苦。5.3 哪些框架值得直接学习如果看完这个Demo还是觉得和真实Rollup差距很大不用焦虑因为这本来就是一篇“最小实现”的拆解。真正工程级Rollup的体量非常大但不是无路可循。我建议想深入的人按这样的顺序去读源码OP StackOptimism将执行环境、批处理、挑战机制全部模块化。它的代码组织得很清晰适合先看合约层的BatchInbox和L1CrossDomainMessenger再看排序器的批处理逻辑。Arbitrum OrbitArbitrum的结算层设计更贴近“多链协作”场景理解它怎么复用L2安全性来启动新链会对扩展方案有更深认知。zkSync和Scroll想转ZK方向的话先别一上来读电路先理解它们怎么把交易执行模型转成可证明的指令集再读证明生成的工程实现。我个人的建议是先从Optimistic系走一遍因为它不需要理解太多密码学就能把整个链路跑通。等你对批次、挑战、状态根这些概念形成直觉后再切ZK方向会容易得多。最后再分享一个我自己保留的习惯每研究一个新Rollup框架都会先做一个最小Demo只跑通“一个账户转给另一个账户”再一步步加复杂逻辑。Rollup这个东西表面上是扩容问题底层其实是数据表达与信任模型的问题。把最小闭环跑顺很多之前看不懂的架构设计再看时就一通百通了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价