从零实现Rollup:以太坊Layer2扩容原理与工程实践
如果你在2021年那轮行情里用以太坊做过转账一定记得一次简单Transfer动辄几十美元Gas费的酸爽。我后来做链上数据分析工具时发现不少项目已经开始把业务从主网迁移到Layer 2而Layer 2赛道里最被主流认可的方案就是Rollup。这篇文章不打算重复白皮书里的大道理而是从一个开发者的角度把Rollup扩容方案的原理、选型、代码实现和踩坑经历完整过一遍。适合对以太坊有一定了解、想深入Layer 2开发或者正在考虑把Rollup技术拿来做实际业务的读者。我写这篇的动机很直接网上讲Rollup的文章理论派居多真正把L1合约、排序器、状态根、批处理串成一整套Demo的很少。很多朋友看完概念还是一头雾水不知道从哪里下手写代码。我就用自己做过的一个最小化Rollup原型来拆解所有代码都能在本地跑起来。先声明一句下面所有代码是一个教学性质的最小实现和生产级的Arbitrum、zkSync有差距但核心架构是一脉相承的能帮你把抽象概念落地成可运行的东西。1. 为什么必须关注Rollup以太坊扩容困境与Layer 2的崛起1.1 拥堵的L1与Gas费上涨以太坊主网L1的本质是一台全球共享的状态机所有节点都要执行同一批交易维护同一份状态。为了保证去中心化和安全性以太坊刻意限制了区块大小与Gas上限这导致整个网络在高峰期能处理的交易量非常有限。我印象很深刻2021年某个NFT项目发售当天普通转账的Gas费一度冲到0.02 ETH那时候做链上业务最头疼的不是合约Bug而是用户根本付不起手续费。在这种背景下整个行业开始围绕“能不能把交易搬到链下执行再把结果锚定回链上”做文章这就是Layer 2的基本思路。Layer 2不是一个新的独立区块链而是借用以太坊的安全性和数据可用性在链下做计算在链上做结算的一整套方案。用户从L2发起提款最终还是要回到L1拿到资产所以L2的安全上限完全由L1来兜底。有人会问为什么不等以太坊自己把性能提上去这里有个很现实的问题L1的每次升级都要考虑到全球分布的成千上万个节点任何一条规则改动都会牵一发而动全身。与其等待一个不确定的扩容方案不如在L1之上叠加一层处理逻辑。这也是为什么Rollup能在短时间内跑出来因为它不改变L1的共识规则只是把L1当成一个“数据可用层仲裁法院”来使用。1.2 为什么最终是Rollup而不是状态通道或Plasma其实在Rollup之前市场上就已经有状态通道、Plasma、侧链等方案它们各有各的优势但都存在明显短板。状态通道适合高频小额的固定双边支付比如两个人之间反复转账通道打开期间不需要在链上记录每一笔交易。但它的致命问题是需要双方都在线而且资金锁定太死一旦通道关闭流程没走对资金可能被没收。Plasma的思路是把交易放在子链上定期向L1提交Merkle根安全性理论上继承L1但退出机制极其复杂数据可用性是个大坑普通用户根本玩不转。侧链的方案更直接就是另起炉灶独立运行一条链但独立链的安全性完全靠自己维护这和直接用一条新公链没有本质区别谈不上“继承以太坊安全性”。Rollup之所以能在竞争里胜出关键就两点。第一交易数据会实实在在上传到L1哪怕Rollup的交易执行结果有问题挑战者也能基于L1上公开的数据发起质疑。第二资产托管在L1合约里L2状态无论如何变化最终都不能绕过L1合约取走用户资产。这两条保证了Rollup的安全模型非常清晰计算可以链下做数据必须链上留痕状态根最终由L1来确认。2. Rollup核心原理拆解两种范式的对比与取舍2.1 Rollup的共识模型计算下沉、数据上链先给一个最直观的理解Rollup就是把以太坊当成一个记事本业务方在链下维护一个高速执行的账本每隔一段时间把账本的最新快照和这段时间内的交易摘要提交到链上。以太坊上的合约只负责两件事一是校验提交人的资格二是记录状态转换的承诺。这里有个必须讲清楚的概念状态根。在一个Rollup系统里账户余额、合约存储、随机数这些信息组合在一起经过哈希运算会产生一个固定长度的摘要一般叫stateRoot。链下执行器每处理完一批交易就计算出一个新的stateRoot然后把旧根、新根、交易摘要一起提交到L1。L1合约不需要重新执行每一笔交易它只需要相信这个状态转换在挑战期内可被验证。“计算下沉”的意思是所有繁重的执行工作比如运行EVM字节码、更新存储、计算哈希全部挪到排序器或验证者节点上去做L1只做轻量级的校验与仲裁。而“数据上链”保证的是任何时候挑战者都能拿到完整交易数据重新执行一遍验证提交的stateRoot对不对。如果数据不上链状态根就成了无源之水用户无法自证清白这是Rollup设计里最重要的一根红线。2.2 Optimistic Rollup与欺诈证明Optimistic Rollup的代表是Arbitrum和Optimism。它的核心假设是“默认所有提交都是正确的”所以提交批次不需要附带证明直接就能上链。但为了防止恶意排序器提交错误状态系统设置了一个挑战窗口一般是7天左右。在窗口期内任意验证者如果发现状态根与交易数据对不上可以发起欺诈证明挑战。欺诈证明的玩法不止一种简单型是把整批交易在链上重新执行发现结果不一致就处罚提交者高级型是二分查找先在链上定位到出错的那一笔交易再对这一笔做精确验证这样能把验证成本降到很低。我实际体验过这种设计它在安全性和效率之间做了很好的平衡。真正动手做的时候关键在于L1合约要把“验证逻辑”和“挑战逻辑”分开避免挑战者在验证过程中被恶意合约卡住。Optimistic Rollup最大的优势是EVM兼容性好Solidity合约几乎不需要改动就能直接迁移上去。因为它的执行环境和L1一致不需要专用编译器。代价就是出金确认时间长用户从L2把资产提回L1得等挑战期结束才能拿到钱虽然市场上有一些快速退出服务商愿意垫资但这是有风险的。2.3 ZK Rollup与有效性证明ZK Rollup这条路线代表项目是zkSync、StarkNet、Scroll。它的逻辑更有“数学味”每次提交批次时提交者必须附带一个零知识证明证明这批交易执行后的状态转换是正确的。L1合约只负责验证证明验证通过就直接确认不设置争议窗口所以提款速度远比Optimistic Rollup快。零知识证明听起来神秘其实在工程上可以当成一个“压缩版执行见证”。链下执行10000笔交易后生成一个大概几十KB的证明文件L1合约不需要知道每笔交易怎么执行只需要运行一个固定的验证电路就能确信结果是正确的。当前效率比较高的方案比如PLONK、STARK各有侧重。STARK证明尺寸大但不需要可信设置PLONK/R1CS证明小但通常依赖可信设置。ZK Rollup的问题在于EVM兼容性。Solidity合约跑在ZK虚拟机里需要把字节码翻译成电路约束这个翻译过程的工程难度非常大。早期很多项目只能支持有限的转账操作后来虽然出了很多兼容方案但复杂度依然摆在那里。另一个痛点是证明生成时间太长一笔批量证明可能要几分钟甚至更久在交易激增时容易导致批次延迟。2.4 名词“Rollup”为什么总是被误用我这个月在好几个技术群里看到有人问“SQL Server里的ROLLUP怎么用”也看到有人把前端构建工具rollup.js和区块链Rollup混为一谈。这不是笑话而是不同领域的重名现象。数据库里的ROLLUP是GROUP BY的扩展用来生成小计和总计本质是一个聚合函数层级。前端构建工具rollup.js是一个模块打包器把多个JavaScript模块打包成单文件。它们和区块链Rollup没有任何从属关系只是恰好都叫同一个名字。区块链Rollup的英文原意是“卷起、累聚”指的是把一批交易聚拢起来处理再压缩成一个状态根提交到L1。如果你准备搜“Rollup代码实现”建议先确认自己想知道的是数据库聚合、前端构建还是L2扩容。方向不对学的内容就完全对不上。3. 从零写一个极简Rollup合约、模拟器与联调3.1 环境准备动手写代码之前先把开发环境准备好。我这里用的技术栈是Hardhat加Solidity 0.8.19离线模拟器用Python 3.9交互脚本用ethers.js。Hardhat是目前最顺手的以太坊本地开发框架自带本地节点、测试账户、合约编译部署一套流程比用Remix写完整应用舒服太多。安装依赖就几条命令先把Node.js 16以上版本装好然后在一个空目录里执行npm init -y npm install --save-dev hardhat nomicfoundation/hardhat-toolbox npx hardhat选择“Create a JavaScript project”Hardhat会自动生成contracts、scripts、test目录。再装一个Python的hashlib库这个不用装标准库自带。我的架构分三块L1合约负责资产托管和批次承诺Python模拟器负责维护L2状态、执行交易、计算状态根最后的联调脚本负责把模拟器生成的批次提交到链上。3.2 L1结算合约代码L1合约是整个Rollup系统的裁判它不需要执行每一笔交易只需要记录排序器提交的状态根并在挑战期结束后确认。下面这个SimpleRollup合约是我在实际Demo里用的版本已经去掉了很多业务逻辑只保留核心骨架// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract SimpleRollup { struct Batch { uint256 committedAt; // 提交时间 bytes32 stateRoot; // L2状态根 bytes32 transactionsRoot; // 交易批次哈希 bool finalized; // 是否已确认 } mapping(uint256 Batch) public batches; uint256 public batchCount; address public sequencer; uint256 public immutable challengeWindow 7 days; bytes32 public currentStateRoot; uint256 public latestFinalizedBatch; event BatchCommitted(uint256 indexed batchIndex, bytes32 stateRoot, bytes32 transactionsRoot); event BatchFinalized(uint256 indexed batchIndex); event Deposit(address indexed token, uint256 amount, bytes32 account); constructor(address _sequencer) { sequencer _sequencer; } modifier onlySequencer() { require(msg.sender sequencer, not sequencer); _; } // 用户往L1托管里充值并注册到L2账户 function deposit(address token, uint256 amount, bytes32 account) external { // 实际项目中需要 IERC20(token).transferFrom(msg.sender, address(this), amount) // 这里为了跑通流程只记录事件不真实转移资产 emit Deposit(token, amount, account); } // 排序器提交一个批次 function commitBatch(bytes32 stateRoot, bytes32 transactionsRoot) external onlySequencer { batches[batchCount] Batch(block.timestamp, stateRoot, transactionsRoot, false); emit BatchCommitted(batchCount, stateRoot, transactionsRoot); batchCount 1; } // 挑战期结束后任意调用者都可以确认批次 function finalizeBatch(uint256 batchIndex) external { Batch storage b batches[batchIndex]; require(block.timestamp b.committedAt challengeWindow, still in challenge window); require(!b.finalized, already finalized); b.finalized true; currentStateRoot b.stateRoot; latestFinalizedBatch batchIndex; emit BatchFinalized(batchIndex); } }这里有几个设计点需要解释。为什么用mapping存批次而不是数组因为生产环境里批次号可能会被挑战、回滚、重排mapping天然支持稀疏索引。为什么finalize要设置成任何人都能调用因为确认批次没有经济利益只有维护义务所以必须鼓励任意节点补位执行一旦排序器宕机其他节点也能推进系统。3.3 离线执行器模拟L2侧的离线执行器我用了Python来写因为要演示状态转换逻辑Python的可读性比Solidity高一个量级。核心就是维护一份账户余额表按顺序执行交易最后计算状态根import hashlib class RollupState: def __init__(self): self.balances {} def apply_transfer(self, from_addr, to_addr, amount): if self.balances.get(from_addr, 0) amount: raise ValueError(f{from_addr} balance insufficient) self.balances[from_addr] self.balances.get(from_addr, 0) - amount self.balances[to_addr] self.balances.get(to_addr, 0) amount def state_root(self): # 教学简化全局排序后拼接哈希 # 生产环境用 Merkle Patricia Trie content .join( f{addr}:{self.balances.get(addr, 0)}; for addr in sorted(self.balances.keys()) ) return hashlib.sha256(content.encode()).hexdigest() def simulate_batch(transactions, initial_state): state RollupState() state.balances dict(initial_state) for tx in transactions: state.apply_transfer(tx[from], tx[to], tx[amount]) return state if __name__ __main__: initial { 0x1111111111111111111111111111111111111111: 1000, 0x2222222222222222222222222222222222222222: 500, } txs [ {from: 0x1111111111111111111111111111111111111111, to: 0x2222222222222222222222222222222222222222, amount: 30}, {from: 0x2222222222222222222222222222222222222222, to: 0x1111111111111111111111111111111111111111, amount: 10}, ] new_state simulate_batch(txs, initial) print(new state root:, new_state.state_root())这个模拟器有两个细节值得注意。第一在apply_transfer里我先检查余额再扣款交易如果失败整个批次应该回滚到未执行状态而不是跳过继续。真实Rollup的区块构建器同样遵循这个原则状态转换是原子的。第二state_root的计算方式我用了最简单的全局拼接哈希这只能用来理解原理。真实项目必须用Merkle Patricia Trie这样即使账户有上亿个也能在O(logN)时间内生成和验证某个账户的状态证明。3.4 端到端调用写完合约和模拟器下一步是把它们串起来。我的做法是先本地起Hardhat节点部署SimpleRollup合约然后用Python模拟器生成一批交易并算出新的stateRoot最后用ethers.js把批次提交到链上。下面是部署和提交的脚本const { ethers } require(hardhat); async function main() { const [deployer] await ethers.getSigners(); const SimpleRollup await ethers.getContractFactory(SimpleRollup); const contract await SimpleRollup.deploy(deployer.address); await contract.deployed(); console.log(SimpleRollup deployed to:, contract.address); // 这里填Python模拟器算出来的stateRoot和transactionsRoot const stateRoot 0x 11.repeat(32); const transactionsRoot 0x 22.repeat(32); const tx await contract.commitBatch(stateRoot, transactionsRoot); const receipt await tx.wait(); console.log(batch committed, tx hash:, receipt.transactionHash); } main().catch(console.error);实际联调的时候我会写一个Python脚本把transactions生成成JSON文件再用Node脚本读取并提交这样可以避免手工复制哈希。整个链条跑通之后你会发现Rollup的核心工作流其实很朴素L2业务逻辑在链下跑成熟一批就向L1交一次作业L1只做登记和裁决。4. 我在联调过程中踩过的坑4.1 nonce与事件顺序第一次联调时我犯了个低级错误用Python的dict保存状态结果Python 3.7之后的dict虽然保留插入顺序但真实业务里交易来源是多线程的可能乱序到达。如果在模拟器里不按nonce排序就执行交易状态根就会算错而且这种错误很难排查因为单笔交易本身都合法。解决办法是在交易结构体里加nonce字段每次执行前按账户和nonce排序遇到nonce空洞就把交易放回待处理队列。还有一个更隐蔽的问题事件顺序。Solidity里面emit事件不会自动排序如果你依赖事件日志来恢复L2状态一定要在合约里记录一个序号字段比如batchIndex这样链下服务才能按序消费日志否则遇到区块重组就会乱套。我建议所有做L2索引服务的朋友不要把区块高度当唯一顺序依据区块高度加交易index加logIndex三层组合才能唯一定位一条事件。4.2 calldata压缩与Gas优化Rollup的成本大头在L1的数据发布而不是L2的计算。以太坊上calldata的写入成本是非零字节16 Gas零字节4 Gas这个数字看着不高但当你每天发布几千个批次时压缩率会直接决定商业模式能不能成立。我在Demo里刚开始用的是原始JSON格式传输一个批次500笔交易calldata大概2MBGas费用高到离谱。后来改成RLP编码再把地址做哈希索引映射成短ID交易类型用一个bit位表示同样的数据压缩到不到200KBGas成本降了大概七八成。生产级Rollup还会用到一些更激进的压缩方案比如把地址替换成索引、金额用变长编码、批量交易共享签名上下文。这里有个经验想分享做Rollup优化先压数据再调证明顺序别反了。很多团队一开始就纠结零知识证明的电路优化结果发现瓶颈根本不在证明生成而在上链价格。4.3 状态根同步问题另一个让我印象很深的坑是状态根的同步时延。在测试网联调时我让排序器每10秒提交一个批次但链下数据服务读取的是本地数据库的L2状态没有和“链上已确认状态”严格区分。结果用户发起提款时合约里保存的currentStateRoot还是旧的导致提款证明验证失败。正确的做法很明确L2执行层需要维护两套状态一套是pending state排序器正在执行但还没上链一套是finalized state已经过了挑战期并确认的状态。用户提款时只认finalized state对应的Merkle证明。这个设计在真实项目里就是“提款最终性”和“快速提款市场”存在的根本原因理解了这两套状态很多L2术语就都好懂了。5. 生产落地不可忽视的安全与架构细节5.1 数据可用性是生死线我在第2章讲了数据上链的重要性这里再展开说。Rollup如果想继承L1的安全性就必须保证任何人在挑战期内都能拿到交易数据。如果排序器提交了stateRoot但把原始交易藏在链外用户无法重新执行验证系统就退化成了“半中心化侧链”用户的资产安全完全依赖排序器的诚实。现在行业里解决数据可用性有两种主流思路。一种是把交易calldata完整发布到L1像Arbitrum、Optimism初期就是这么做的简单可靠缺点是贵。另一种是采用数据可用性采样把数据分片后随机抽样验证比如Celestia那套思路成本低但引入了新的信任假设。从安全和合规的角度看做资产类业务必须选择最保守的方案把数据完整放L1不要为了省钱牺牲可用性。开发者在设计L1合约时也要预判到数据可恢复的场景最好内置一个逃生舱机制。比如当排序器长时间不提交批次时用户可以强制在L1发起退出把资产从托管合约里赎回来。这不是一个额外功能而是Rollup的“宪法性权利”。5.2 排序器去中心化排序器负责接收用户交易、构建批次、提交L1这个角色在早期几乎都是项目方自己运行。它是Rollup生态里最中心化的组件因为排序器能看到所有用户交易可以决定交易打包顺序甚至在极限情况下审查某些地址的请求。这引发了一个关键问题如何防止排序器作恶用户的保护机制是强制交易和提款通道。哪怕排序器拒绝打包某笔交易用户仍然可以直接向L1合约提交请求触发强制包含。在建Demo时我没有实现这部分但生产系统里这是必不可少的逃生窗口。另一条路是把排序权拍卖或者用一组去中心化排序器轮换出块结合共识协议形成共享排序器网络这已经是目前L2领域最前沿的课题之一。如果你只是自己做一个小型Rollup内部试用单人排序器问题不大。如果面对真实用户就必须认真对待这个单点否则一次停机会让所有用户被卡住一次恶意排序会造成系统的信任崩塌。5.3 安全审计清单最后整理一份我在代码审查时反复对照的清单虽然不能替代专业审计但能帮你提前排除绝大多数低级问题。审计点风险说明缓解措施签名重放同一笔L2交易被恶意重复纳入多个批次交易字段带nonce每个账户递增批次内去重排序器越权非授权地址调用commitBatch只有sequencer角色可提交多重签名加固状态冲突同一高度提交了不同stateRoot挑战期内拒绝冲突状态挑战成功则惩罚提交者重入攻击deposit回调恶意token合约多次扣款先转资产再更新状态或者用防重入锁提款证明不完整用户伪造Merkle分支合约必须验证Merkle根和分支匹配不允许跳过参数溢出金额相加导致uint256溢出使用SafeMath或Solidity 0.8自带溢出检查挑战窗口太短验证者来不及发现恶意批次按系统规模设置足够窗口并考虑动态调整机制我每次做Rollup合约审计都有一个习惯拿转移资产的路径逐笔走一遍从L1存款到L2入账再到L2出款回L1任何一环断裂都是致命的。建议你也画一张资金流向图每段流程对照合约函数逐个检查权限、时间锁、证明验证三个维度。按照我这个Demo跑一遍你对Rollup的整个数据流会有非常清晰的体感。我个人实际开发中的体会是这类系统里最难的不是某个算法而是把所有组件的时序关系对齐。最后再分享一个小经验做L2开发一定养成先定义清楚“谁能做什么、什么时候能做”的习惯把权限和时序画在先再动手写合约比回头重构省太多时间。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →