1. 内容整体设计与思路拆解
1.1 为什么 Layer 2 扩容成了以太坊绕不开的话题
先聊一个很现实的痛点。以太坊主网的高 Gas 费和拥堵问题,从 2020 年 DeFi Summer 开始就暴露得淋漓尽致。一笔简单的转账在高峰期能烧掉几十美元 Gas,Uniswap 上的一个 swap 操作可能就要等上几分钟甚至更久才能确认。这种情况对普通用户来说已经很难接受了,更别提那些需要高频交互的链游、社交应用、衍生品交易平台。
说到底,以太坊主网每秒只能处理 15 到 20 笔交易,这个吞吐量放在传统互联网世界里几乎不值一提。而 Rollup 这种方案之所以能在众多扩容思路里杀出重围,核心逻辑其实很朴素——把绝大多数计算和存储搬到链下,链上只保留最精简的“账本摘要”。这样既能享受以太坊主网的安全保障,又能把手续费降一个数量级以上。对比一下几个主流方案你就明白了:状态通道适合低频大额转账,Plasma 在数据可用性上有明显短板,而侧链需要额外的信任假设。Rollup 则是直接继承了主网的安全性,不需要新的共识机制,这也是为什么 Vitalik 和整个社区最终把宝押在了它身上。
1.2 两种 Rollup 的技术路线之争
Rollup 这个概念底下其实分了两个派系:Optimistic Rollup 和 ZK-Rollup。名字看着都有个 Rollup,但底层逻辑完全不一样。
Optimistic Rollup 的思路用一句话总结就是“默认相信,留有后手”。它假设提交到链上的批量交易数据大概率是诚实的,所以不立刻做验证,而是留出一段挑战期。如果有人发现某个状态根有问题,就可以发起欺诈证明来挑战。如果挑战成功,作恶者质押的资金会被罚掉一部分。这种设计把计算量压到了非常低的水平,因为大部分时候交易批次的正确性根本不需要被验证。
ZK-Rollup(零知识证明 Rollup)则走了一条更激进的路——用密码学证明来直接保证正确性。它把每一批交易都打包成一个零知识证明,链上的验证合约只需要验证这个证明是否合法,就能确认整批交易的状态根是否正确。这种方式不需要挑战期,资金确认速度快得多,但劣势也很明显:证明生成阶段的计算量极大,对硬件有很高要求。
坦率地说,在我实际开发和测试的过程中,ZK-Rollup 的电路开发难度确实吓退了一大批团队。而 Optimistic Rollup 因为能直接兼容 EVM,几乎不需要改动现有的 Solidity 合约,短期内落地成本最低。从设计思路上,这就是一个“开发成本”和“用户体验”之间的权衡。如果让我给刚入门的开发者一个建议:先从 Optimistic 方案的代码读起,理解 Batch 提交和挑战机制之后,再啃 ZK 证明生成,学习曲线会平缓很多。
1.3 我们要实现一个什么样的 Rollup
这篇文章里我要带大家做的,不是一个完整的商业级 Rollup 工程,而是一个“麻雀虽小,五脏俱全”的最小原型系统。它需要具备以下核心组件:
- 链下排序器(Sequencer):负责接收用户交易、排序、打包成批次,然后计算新的状态根。
- 链上验证合约(Rollup Contract):接收排序器提交的交易批次数据和新的状态根,并处理用户的存款和取款请求。
- 状态转换函数(State Transition Function):这是一切 Rollup 逻辑的核心,定义了交易如何改变状态树。
- 交易格式与签名校验:确保每一笔交易都是真实用户在授权状态下发出的。
我会用 Solidity 写链上合约部分,用 JavaScript 写链下的排序器和状态管理。为了不引入 IPFS 这种额外复杂度,链上存储交易数据的方式我会用calldata来实现——这种方式正好也是以太坊数据分片(Dencun 升级前)之前 Rollup 项目普遍采用的方案,对理解数据可用性(Data Availability)的概念非常有帮助。
注意:我们在本地测试环境里跑的是 Hardhat 自带的内存链,所以不需要真的去支付 Gas。但你在做真实部署或者把代码跑到 Goerli 测试网的时候,一定要时刻盯住合约调用的 Gas 消耗,因为 Rollup 合约的每一次提交操作都会被全网节点重复执行,而这也是 Rollup 能比 Layer 1 便宜的核心原因——它把昂贵的外部验证推迟到了争议期。
2. 核心细节解析与实操要点
2.1 Rollup 的架构组件到底在各自干什么
我们把这个系统的每个组件拆开来看。
排序器是整个 Rollup 系统里唯一有“打包”权力的角色。用户第一眼理解的 Rollup,往往就是“有人帮我把很多笔交易捆在一起处理”。这个“有人”通常就是排序器。排序器接收用户发起的一笔笔交易,校验签名、检查账户余额、执行状态转换,然后生成一个新的状态根。一个批次里可能包含几十甚至几百笔交易。链上合约不关心每笔交易的具体细节,它只验证数据是否在链上、新状态根是否被正确提交。
链上验证合约则是 Rollup 和主链之间的桥梁。用户往 Rollup 里转入资金时,钱是在这个合约里锁定的,状态树中就会增加一个账户记录。用户想取款时,合约会把状态根作为依据,把对应的资产释放到取款人地址。这里有一个容易搞混的点:状态树里记录的是一个“Rollup 内部账户”的非ce、余额等信息,它和主链上的地址是映射关系,但链上合约只认状态根,不会去逐笔核对交易逻辑。
状态树本身,在主流实现里几乎都用的是 Patricia Merkle Trie(在以太坊里是 MPT,简化版用 Merkle Tree 就够了)。Merkle Tree 最大的好处在于,你不需要把所有账户数据都放到链上,只需要在链上保存一个几十字节的根哈希,就能锁定整棵树的状态。任何一个叶子节点的变化,都会导致根哈希的变化。任何人都可以随时提交一个“我要证明我的账户余额是多少,且这个状态是合法的”的证据,在树里通过默克尔路径验证。
2.2 代码仓库结构和模块规划
我习惯把代码分成contracts、src和test三块,这是一个非常常规的工程结构,方便维护也能让协作的同事一眼找到自己需要的东西。
rollup-demo/ ├── contracts/ // 链上合约(Solidity) │ ├── Rollup.sol // Rollup 主合约 │ ├── lib/ │ │ └── MerkleTree.sol // 默克尔树封装 │ └── interfaces/ │ └── IRollup.sol ├── src/ // 链下模块(JavaScript) │ ├── sequencer.js // 排序器 │ ├── state.js // 状态管理器和状态转换 │ ├── tree.js // 默克尔树(JS 实现) │ └── wallet.js // 钱包/签名帮助模块 └── test/ // 自动化测试 └── rollup.test.js为什么要单独把树和状态逻辑抽出来,而不是全部写在 Solidity 合约里?因为我们需要在链下用 JavaScript 把状态根提前算出来,再把结果提交到合约。如果你让合约也维护一棵完整的树,那 Gas 成本会高到失去意义——合约里只应保存树的根,而不是树的全部。
实操心得:一开始我犯过一个非常典型的错误——把整棵默克尔树的节点都存在了合约里,结果是跨链调用时 data 极其臃肿,一笔交易能花掉一两千 Gas,完全违背了 Rollup 的初衷。后来才明白,正确的做法是链下算好新根,提交到链上,让合约校验数据可用性即可。
2.3 为什么选择 JavaScript 而不是 Python 或者 Go
这纯属生态取舍。目前最完善的以太坊开发工具链(Hardhat、Ethers.js、Viem)都是 JavaScript/TypeScript 的天下。你写链下逻辑的时候,免不了要和钱包私钥、ABI、合约交互打交道,用 JS 做这些事情几乎是无缝衔接的。当然,Python 也有好用的 Web3.py,但我个人觉得索引和事件监听生态没有 JS 丰富。
还有一个关键点是:Rollup 排序器本质上是一个持续运行的服务,Node.js 的事件循环模型天然适合这种 I/O 密集型的消息监听任务。你可以它长期跑着,监听用户提交的入金事件,然后触发打包。如果用 Python 做这件事也不是不行,但连接状态管理和异步处理会稍微别扭一点。我这么说希望不要引发语言之争——框架选择永远是看项目需求和团队擅长的,不是“谁比谁高贵”。
在package.json里,我只需要这样几个依赖:
{ "dependencies": { "ethers": "^6.7.0", "hardhat": "^2.19.0", "mocha": "^10.2.0" }, "scripts": { "build": "hardhat compile", "test": "hardhat test" } }2.4 链上合约的接口设计
在写任何业务逻辑之前,先把接口定义出来,这在团队协作中尤其重要。我的IRollup.sol大概长这样:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; interface IRollup { // 用户将主网资产锁定到合约,映射到 Rollup 内部账户 function deposit(address rollupAccount) external payable; // 排序器提交一个交易批次和新的状态根 function submitBatch(bytes calldata transactions, bytes32 newStateRoot) external; // 用户在 Rollup 内部验证通过后,可以取回资产 function withdraw(bytes32 merkleProof, uint256 amount) external; // 事件,方便链下索引器监听 event Deposit(address indexed account, uint256 amount); event BatchSubmitted(uint256 indexed batchNumber, bytes32 stateRoot); event Withdrawal(address indexed account, uint256 amount); }注意这里的withdraw函数里传了merkleProof——这就是我之前说的默克尔路径证明。用户需要在线下自己生成一条从自己账户叶子到树根的路径,合约收到证明后能确认“这个账户确实在这棵状态树里有这么多余额”,然后才释放资产。这个过程不需要合约知道整棵树,只需要用默克尔验证算法递归校验哈希即可。
很多同学在这里会卡壳:为什么用户不能直接把地址和金额提交上来?因为合约必须确保“这个地址在这个 Rollup 状态中有至少这么多资金”——如果合约坚信任何人提交的数据都是真的,那攻击者直接提走所有人的钱就行了。默克尔证明的存在,就是为了把“状态根”和“叶子节点”之间的关系锁死。
在下一节我会给出这套机制的完整实现代码,并且把每步操作对应的 Gas 与验证成本摊开讲清楚。
3. 实操过程与核心环节实现
3.1 从零搭建本地开发环境
首先你要确认自己已经装了 Node.js,建议版本 18 以上。然后我们创建一个新目录并初始化 npm 项目:
mkdir rollup-demo cd rollup-demo npm init -y npm install --save-dev hardhat ethers@^6 @nomicfoundation/hardhat-ethers mocha npx hardhat initnpx hardhat init会出现交互式提示,我建议选择 “Create a JavaScript project”,插件选@nomicfoundation/hardhat-ethers,这样能省掉一些后续的手动配置。Hardhat 默认生成的hardhat.config.js就已经能用了,但我习惯加上本地链的配置,方便之后部署和测试:
require("@nomicfoundation/hardhat-ethers"); module.exports = { solidity: "0.8.19", networks: { hardhat: { chainId: 31337, }, }, };本地开发环境本质上就是一个模拟的 EVM 环境,它不会真的去跑 PoS 共识,也不会受 Gas 价格波动的影响,非常适合我们的场景。唯一要注意的是,一旦你部署合约后切换了 Hardhat 网络的fork配置(比如为了测试某些主网状态而 fork 主网),那么之前部署的合约地址全部无效,需要重新跑一遍部署脚本。
3.2 默克尔树实现中最容易写错的一个细节
默克尔树是一个完全二叉树,叶子节点是账户数据的哈希,父节点是两个子节点哈希再哈希的结果。看起来很简单,但有一个细节非常关键——当节点数量不是 2 的幂的时候,最后一层多余的节点怎么处理?主流方案是补零节点(zero nodes),具体做法是:先规定一个固定高度(比如 10 层,可以容纳 2^10 = 1024 个账户),然后把缺失的位置用keccak256(abi.encodePacked(uint256(0)))来填充。这个 zero hash 是固定的、公开可计算的。
我在 JavaScript 里实现的树大概长这样:
const { ethers } = require("ethers"); class MerkleTree { constructor(height) { this.height = height; this.leafCount = 2 ** height; this.nodes = new Array(this.leafCount).fill( ethers.ZeroHash ); } // 将高度转为“填充 zero hash”的索引 _getLeafIndex(address, nonce) { const hash = ethers.keccak256( ethers.solidityPacked(["address", "uint256"], [address, nonce]) ); return parseInt(hash.slice(0, 16), 16) % this.leafCount; } setAccount(address, nonce, balance) { const leaf = this._hashAccount(address, nonce, balance); const idx = this._getLeafIndex(address, nonce); // 更新第 idx 层的叶子 this.nodes[this.leafCount - 1 + idx] = leaf; this._recalculatePath(idx); } _hashAccount(address, nonce, balance) { return ethers.keccak256( ethers.solidityPacked( ["address", "uint256", "uint256"], [address, nonce, balance] ) ); } _recalculatePath(idx) { let position = this.leafCount - 1 + idx; while (position > 0) { const parent = Math.floor((position - 1) / 2); const sibling = position % 2 === 0 ? position - 1 : position + 1; this.nodes[parent] = ethers.keccak256( ethers.concat([this.nodes[position], this.nodes[sibling]]) ); position = parent; } } getRoot() { return this.nodes[0]; } }看到_getLeafIndex这个方法了吗?我直接取地址加 nonce 的哈希前 16 位转换成十进制再取模,用这个方式来确定叶子位置。这个设计在实际的 Optimism 或 Arbitrum 的代码里不是这样做的,它们用的是带 index 哈希的复杂结构,但作为一个小型教学 Demo,这种简化的确定性映射足够用,也更容易理解。不过要提醒的是:不要在生产环境中采用这种不够安全的冲突概率较高的方式,正式实现建议使用有序的映射结构或者一个显式的“地址→叶子序号”状态存储。
我在测试过程中发现,最容易出错的地方是叶子节点的哈希参数顺序。如果你在 Solidity 合约验证时候用的参数顺序和链下 JS 不一致,那么即使内容相同,算出来的哈希也是天壤之别。所以我强烈建议在写 Solidity 验证函数之前,先写一个基于同样参数顺序哈希的 JS 版本,然后跑一组兼容性测试,确保两边输出一致。
3.3 链上验证合约的完整实现
Rollup.sol是我们整个系统的主干。它的核心功能有三个:
- 存取款:处理用户在主链与 Rollup 之间的资金流转。
- 批量提交:接收排序器提交的交易数据和新的状态根。
- 默克尔验证:校验取款请求对应的账户是否有足够余额。
合约代码如下:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract Rollup { uint256 public constant BATCH_SIZE = 10; address public sequencer; bytes32 public stateRoot; uint256 public batchCount; mapping(address => uint256) public deposits; event Deposit(address indexed account, uint256 amount); event BatchSubmitted(uint256 indexed batchNumber, bytes32 stateRoot); event Withdrawal(address indexed account, uint256 amount); modifier onlySequencer() { require(msg.sender == sequencer, "not sequencer"); _; } constructor() { sequencer = msg.sender; } // 用户存款,调用该函数会锁定主网资产 function deposit() external payable { require(msg.value > 0, "zero amount"); deposits[msg.sender] += msg.value; emit Deposit(msg.sender, msg.value); } // 排序器提交批次数据 function submitBatch(bytes calldata txData) external onlySequencer { // 长度必须是 BATCH_SIZE 且每条交易为定长 256 字节 require(txData.length % 256 == 0, "invalid tx data"); require(txData.length / 256 <= BATCH_SIZE, "batch too large"); // 链下计算出来的 stateRoot 也作为 calldata 一起传进来更合理 // 但因为这里演示简单,让排序器从 txData 来计算 bytes32 newRoot = _processBatch(txData); stateRoot = newRoot; batchCount++; emit BatchSubmitted(batchCount, newRoot); } // 处理批次,返回新的状态根 function _processBatch(bytes calldata txData) internal view returns (bytes32) { // 在这个最小实现里,我们不对交易内的具体业务逻辑做解码验证, // 完全信任排序器,只是将整个批次数据的哈希作为新状态根的输入。 // 生产级实现需要在这里执行状态转换函数(STF)并结合默克尔树更改账户状态。 return keccak256(txData); } // 取款:用户提供自己要取出的资金额,合约判断其 Rollup 余额是否允许这么取出 // 注意:这个最小实现假设 Rollup 内的余额始终等于 deposits[msg.sender] - 已取金额 function withdraw(uint256 amount, bytes32[] calldata merkleProof) external { require(deposits[msg.sender] >= amount, "insufficient balance"); deposits[msg.sender] -= amount; // 真正的完整实现中,这里需要结合 stateRoot 以及 merkleProof 验证用户的账户余额 // 并且把验证通过后应将 amount 转给 msg.sender payable(msg.sender).transfer(amount); emit Withdrawal(msg.sender, amount); } }这段代码为了教授原理做了大量简化,我把链下状态迁移和默克尔验证的部分留到了可扩展的接口里。在真实的生产级实现中(比如 Optimism),_processBatch会针对每一笔用户交易解码出from、to、amount,然后调整一棵状态树,最终得到新的根。这里我用keccak256(txData)来做演示级别的状态根,本质上就是一个针对交易数据的哈希指纹。如果你对这个最小实现不满意,完全可以在_processBatch中做更细粒度的状态跟踪。
3.4 排序器的 JavaScript 实现
排序器要干的事很简单:把一堆待处理交易打包,计算出新状态根,然后提交到链上。这里我写成了一个 Node.js 脚本,可以理解为定位为“服务主循环”的雏形:
const { ethers } = require("ethers"); const { MerkleTree } = require("./tree"); class Sequencer { constructor(contractAddress, provider, signer) { this.contract = new ethers.Contract(contractAddress, abi, signer); this.tree = new MerkleTree(10); this.pendingTxs = []; this.signer = signer; } // 收到用户交易后加入待处理队列 addTransaction(txObj) { // txObj: { from, to, amount, nonce, signature } this.pendingTxs.push(txObj); } // 打包一个批次 async submitBatch() { const txs = this.pendingTxs.splice(0, 10); // 每次最多取 10 笔 if (txs.length === 0) return; // 1. 对每笔交易做签名恢复/验证(伪代码) for (const tx of txs) { if (tx.from === tx.to) continue; // 转账给自己无效 this.tree.setAccount(tx.to, this._nonceOf(tx.to) + 1, tx.amount); } // 2. 编码成一个 bytes const encoded = txs.map(tx => ethers.solidityPacked( ["address", "address", "uint256", "uint256", "bytes"], [tx.from, tx.to, tx.amount, tx.nonce, tx.signature] ) ); const batchData = ethers.concat(encoded); // 3. 计算新状态根 const newRoot = this.tree.getRoot(); // 4. 将提交调用发送到链上 const tx = await this.contract.submitBatch(batchData); await tx.wait(); console.log(`Batch submitted. New state root: ${newRoot}`); } }这一步里,排序器只是把一个批次的数据压成了一个字节串,然后调用了链上合约。链上合约用_processBatch计算了状态根,其实这里对应的逻辑现实中是由排序器预先算好传给合约的。你可能会问,排序器这么干,如果它提交了一个伪造的状态根怎么办?
这就是挑战机制要处理的问题了。在 Optimistic Rollup 里,任何验证者都可以在挑战期内检查这个状态根是否与区块中交易数据一致。如果发现不一致,就可以调用欺诈证明合约发起挑战。我们这套最小实现里没有做欺诈证明,你会注意到submitBatch只验证了数据格式,没验证每个交易的合法性——这确实是生产版和教学版的明显分界线。
3.5 部署和联调完整流程
写好合约和链下模块后,我们来实际跑一遍。需要用 Hardhat 写一个部署脚本:
// scripts/deploy.js const { ethers } = require("hardhat"); async function main() { const Rollup = await ethers.getContractFactory("Rollup"); const rollup = await Rollup.deploy(); await rollup.waitForDeployment(); console.log("Rollup deployed to:", await rollup.getAddress()); } main().catch(console.error);跑部署命令:
npx hardhat run scripts/deploy.js然后我会在同一个终端里再开一个 Node 交互环境,模拟用户存款和排序器提交:
const { ethers } = require("hardhat"); async function demo() { const [alice, bob] = await ethers.getSigners(); // 获取合约 const Rollup = await ethers.getContractFactory("Rollup"); const rollup = await Rollup.deploy(); // Alice 存款 2 ETH await rollup.connect(alice).deposit({ value: ethers.parseEther("2") }); // 构造两笔交易:Alice 转 1 ETH 给 Bob const tx1 = { from: alice.address, to: bob.address, amount: ethers.parseEther("1"), nonce: 0, signature: "0x", }; const sequencer = new (require("../src/sequencer"))( await rollup.getAddress(), ethers.provider, alice ); sequencer.addTransaction(tx1); await sequencer.submitBatch(); // 查看状态根 console.log("stateRoot:", await rollup.stateRoot()); } demo();如果你能顺利在终端里看到Batch submitted. New state root: 0x...的输出,就说明整个链路已经打通:用户产生交易 → 排序器打包 → 链上合约验收 → 状态根更新。这是整个 Rollup 扩容方案实战中最核心的一个闭环。
3.6 参数计算:一次提交到底能省多少 Gas
我们用一个很简单的公式来估算 Rollup 的扩容效果。
以太坊主网每笔转账的 Gas 消耗大概在 21,000 左右。但在 Rollup 里,一个批次里塞了 N 笔交易,链上合约只需要执行一次submitBatch调用。假设这个调用消耗 500,000 Gas(已经算上 calldata 开销),那么每笔交易的链上成本是 500,000 / N。当 N = 10 时,就是 50,000 Gas,比主网 21,000 还高,这不划算。但当 N = 100 时,就降到了 5,000;N = 1000 时只有 500。所以 Rollup 的效率提升高度依赖批量大小。
这还没算链下排序器执行状态转换的 CPU 成本,但在 Gas 计费的语境下,这个成本对使用者的感知影响不是最关键的部分。也是因为这样,Dencun 升级引入了 EIP-4844 的 Blob 数据,让数据可用性的成本又降了一个数量级——这个思路本质上就是把交易数据放到一个更便宜的临时存储区,验证后就不需要永久保存了。
回到我们的最小实现,我设置BATCH_SIZE = 10纯属演示方便。如果你想在测试网上做压力测试,可以把它改大一点,比如 256。但要注意,txData.length % 256 == 0这个学校代码只能应对定长交易编码,如果将来交易类型复杂了,最好改用更灵活的编码协议(RLP 或自定义结构体)。
提示:不要在一开始就把排序器设计成一个纯异步的“无限事件循环”。先用
submitBatch()这种手动触发的方式跑通全流程,再抽象出事件监听自动打包,这样定位问题会容易得多。很多项目在早期就因为过度工程化,最后连一个简单的转账流程都跑不通。
4. 常见问题与排查技巧实录
4.1 排序器提交时报错 “invalid tx data”
这是我自己第一次跑通代码时遇到的最常见错误之一。原因其实特别蠢:我在构造batchData的时候用的是ethers.concat(encoded),但其中某一笔交易的字段没按照 Solidity 侧bytes.concat(abi.encodePacked(...))的规则来编码,导致长度对不上。
排查思路非常明确:先在submitBatch里加一个require(txData.length % 256 == 0),然后在排序器里把encoded的每个元素长度打印出来。正常情况每笔交易固定是 256 字节,如果你打印出来是 255 或 257,那说明你在拼字段时丢了一个uint256或者多了一个空字节。
这条教训也适用于所有 Layer 2 开发:编码方式的版本统一必须放在测试的第一优先级。我在项目里特意写了一个测试,把同一笔交易用 JS 和 Solidity 分别编码,然后断言结果一致。别看这只是一个简单的测试,它能挡掉后来无数由于类型字节数不一致导致的问题。
4.2 状态根不一致:链下算的跟链上合约算的不一样
这也是一个很隐蔽的坑。我在一个实际项目中遇到过链下算出的状态根和链上提交的逻辑总是对不上,最后发现是因为 Solidity 里keccak256(abi.encodePacked(a, b))和 JS 里ethers.solidityPacked处理uint256时的字节补齐规则不同造成的。
abi.encodePacked对于小于 32 字节的基础类型会压缩,比如uint8只占 1 个字节,但是ethers.solidityPacked也是压缩的,所以一致。但如果你在 JS 里传了一个字符串数字,比如amount: "1000000000000000000",而 Solidity 侧把它当成uint256类型,两边哈希出来的结果就是一样的。真正容易出错的是数组和动态类型,比如bytes memory或者string,排错时需要非常小心。
我的建议是:在状态根计算时,尽量只用基础类型(address、uint256、bytes32),不要在同一个哈希里塞动态长度字段。如果必须塞,就先用keccak256把它固定成一个bytes32再参与上层哈希。
4.3 取款时默克尔证明一直验证不通过
如果你把默克尔树从 10 层改成了别的深度,或者新插入的账户数量没有刷新整棵树,就会出现取款证明验证不通过的问题。我踩过最深的一个坑是:没有在setAccount后重新计算所有祖先节点的哈希,导致叶子变了,但根没变,链上验证时拿到的旧证明自然对不上。
解决的办法是在setAccount里做完叶子更新后,必须从叶子所在层一直往上重算到根。另外,在 Solidity 验证时需要使用和链下完全相同的排序逻辑:要么都是 “左子结点 + 右子结点” 的顺序,要么反过来,否则哈希结果必然是错的。纯小白最容易在这里栽跟头,因为报错信息通常是invalid merkle proof,你根本不知道是树的问题还是路径顺序的问题。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
submitBatch报 “invalid tx data” | 交易数据编码长度非 256 倍数 | 打印每个 tx 编码后长度 | 统一字段字节数,用定长结构体 |
| 链下状态根与链上不一致 | 哈希参数顺序或类型编码不同 | 分别打印 JS 和 Solidity 的哈希输入 | 用同一编码函数并写兼容性测试 |
| 默克尔证明验证不通过 | 叶子更新未递归到根 | 检查更新后 getRoot() 是否变化 | 重新计算祖先节点哈希 |
| 取款后账户余额未减少 | 忘记更新 deposits 映射 | 检查事件日志和 mapping 值 | 在 withdraw 中同步扣减 |
| 排序器提交不触发事件 | 调用的是非 sequencer 地址 | 检查 msg.sender 是否等于部署者 | 部署时记录 sequencer 地址 |
| 部署后合约调用无响应 | Hardhat 本地网络被重置 | 重启节点后地址失效 | 持久化部署脚本,重复执行 |
| 交易被 pending 卡住 | nonce 重复或未签名 | 检查交易对象的 nonce | 构造时用当前账户 nonce + 1 |
| 批量过大 超过 BATCH_SIZE | 排序器一次加入超过限制的交易 | 打印 pendingTxs.length | 在 addTransaction 时就限制数量 |
这张表基本囊括了我自己开发 Rollup Demo 时期遇到的所有主要问题。当然,真实生产环境还涉及到更多基础设施层的细节,比如数据库回滚、API 服务幂等性、消息队列顺序保证等等,但如果你能把表里的问题全部搞清楚,Rollup 的核心机制就已经被你掌握了。
4.5 从 Demo 到生产级实现的路线图
当你把这里的 Demo 跑通、理解了整套逻辑之后,如果想再往前一步,我有几条比较务实的路径可以供你参考。
第一个方向是补充欺诈证明。在 Optimistic Rollup 里,这是保证信任安全的重要一环。你可以为状态转换写一个断言电路(或者用类似 Cannon 这种交互式欺诈证明框架),让任何人都能在挑战期内质疑一笔非法状态迁移。
第二个方向是引入状态通道和跨链通信。比如把资产从 Rollup 跨回 Layer 1 时,需要一个相对长时间的解绑窗口,反方向则需要事件监听和自动执行。这个过程涉及两个链的同步状态管理,复杂度会成倍上升。
第三个方向是尝试使用现有的 Rollup SDK,而不是全部从零造轮子。OP Stack、Arbitrum Orbit、zkSync 的 ZK Stack 都给开发者提供了模块化框架,你可以在保留自己 Rollup 逻辑的同时,把底层的共识、DA、证明系统交给经过审计的组件去处理。这样一来,你既实现了“发散创新”,又不需要从数学证明到工程架构全部自己扛。
我个人更推荐第三种路径。技术圈里最容易被一说就懂的坑,是“重新发明轮子”。理解了 Rollup 的核心原理再做 SDK 层面的二次开发,效率会高得多,也更贴近你真实工程中需要的落地价值。
最后再分享一个我实践中的体会。做 Rollup 这个方向,最难的部分其实不是代码实现,而是脑子上形成一个“验证与信任分离”的框架:链下可以随便计算、随便优化,但链上只保留确定性、可验证的约束。想明白这一层,你再回来看主网上的合约、数据可用性、状态根挑战,很多细节会一下子串成线。这种视角转换,是写几个普通智能合约都换不来的。希望这篇实战分享能让你少走一些弯路。