指南|打造自己的 Rollup 解决方案
BYOR是什么?
BYOR 项目是一种简化版的主权 Rollup 解决方案。与乐观 Rollup 和零知识证明 Rollup 不同,主权 Rollup 不需要在以太坊上验证状态根,而是仅依赖于以太坊上的数据可用性和共识机制。这种设计不仅最大程度地减小了L1和BYOR之间的信任桥梁,还使代码更加简化,非常适合用于教育和学习目的。

该项目由三个主要组件组成:智能合约、节点和钱包。当这些组件一起部署时,它们允许最终用户与网络进行交互。有趣的是,网络的状态完全由链上数据确定,这意味着实际上可以运行多个节点。此外,每个节点都有能力独立发布数据,充当排序器(Sequencer)。
下面是 BYOR 中实现的完整功能列表:
- 费用排序
- 将状态发布到 L1 并从 L1 获取状态
- 丢弃无效的交易
- 查看账户余额
- 发送交易
- 查看交易状态
使用钱包
在钱包应用中,它扮演了网络的前端角色,允许用户提交交易并查看其账户或交易的状态。在登录页面上,您将看到一个概览部分,其中提供了有关 Rollup 当前状态的一些统计信息,接着是您的账户状态。通常,这里将包括一个按钮,用于连接您选择的钱包,并可能还有有关代币水龙头的信息。下方还有一个搜索栏,您可以在其中粘贴其他用户的地址或交易哈希以探索二层网络的当前状态。最后,还有两个交易列表:一个是二层内存池中的交易列表,另一个是发布到一层的交易列表。
要开始使用WalletConnect按钮连接您的钱包。连接后,您可能会收到一条通知,提示您的钱包连接到了错误的网络。如果您的应用程序支持网络切换,请点击“切换网络”按钮以切换到Holesky测试网络。否则,请手动进行网络切换。
通过提供接收者的地址、要发送的代币数量以及所需的手续费来向某人发送代币。一旦发送,钱包应用程序将提示您签署消息。成功签署后,消息将被发送到L2节点的内存池中,等待发布到L1。请注意,交易被捆绑到批次发布中需要一些时间,具体时间可能因手续费和交易量而有所不同。每隔10秒,L2节点会检查是否有待发布的交易。较高手续费的交易将被优先发送,因此,如果您设置了较低的手续费并且有大量交易流量,可能需要更长的等待时间。
工作原理
技术栈
使用以下技术构建了每个组件:
- 节点: Node.js, TypeScript, tRPC, Postgres, viem, drizzle-orm
- 钱包: TypeScript, tRPC, Next.js, WalletConnect
代码深入解析
智能合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;contract Inputs {
event BatchAppended(address sender);
function appendBatch(bytes calldata) external {
require(msg.sender == tx.origin);
emit BatchAppended(msg.sender);
}
}
这个智能合约的命名灵感来源于其主要任务,即将输入数据存储为状态转换函数。该合约的唯一目的是为了方便地记录并存储所有交易。通过将序列化的交易批次作为 calldata
发布到这个智能合约,它将触发一个名为 BatchAppended
的事件,并附带批次发布者的地址。虽然我们本可以设计系统,使其能够直接将交易发布到外部拥有账户(EOA),但通过发出事件,可以轻松通过JSON-RPC接口获取数据。这个智能合约的唯一要求是不应该从其他智能合约内部调用它,而应该直接由外部拥有账户(EOA)调用。
数据库模式
CREATE TABLE `accounts` (`address` text PRIMARY KEY NOT NULL,`balance` integer DEFAULT 0 NOT NULL,`nonce` integer DEFAULT 0 NOT NULL);CREATE TABLE `transactions` (`id` integer,`from` text NOT NULL,`to` text NOT NULL,`value` integer NOT NULL,`nonce` integer NOT NULL,`fee` integer NOT NULL,`feeReceipent` text NOT NULL,`l1SubmittedDate` integer NOT NULL,`hash` text NOT NULLPRIMARY KEY(`from`, `nonce`));-- This table has a single rowCREATE TABLE `fetcherStates` (`chainId` integer PRIMARY KEY NOT NULL,`lastFetchedBlock` integer DEFAULT 0 NOT NULL);
这是整个数据库模式,用于存储与Rollup有关的信息。您可能会想,既然所有必要的数据都存储在L1上,为什么还需要一个数据库。尽管这个观点是正确的,但将数据存储在本地数据库中可以通过避免重复数据拉取来节省时间和资源。在这个数据库模式中,所有存储的数据可被看作是有关状态、交易哈希以及其他计算信息的备忘录。
fetcherStates
表用于追踪我们在搜索 BatchAppended
事件时获取的最后一个区块。这对于节点的关闭和重新启动非常有用,因为它允许节点知道从哪里继续搜索数据,以便进行恢复操作。
状态转换函数
const DEFAULT_ACCOUNT = { balance: 0, nonce: 0 }
function executeTransaction(state, tx, feeRecipient) {
const fromAccount = getAccount(state, tx.from, DEFAULT_ACCOUNT)
const toAccount = getAccount(state, tx.to, DEFAULT_ACCOUNT)
// Step 1. Update nonce
fromAccount.nonce = tx.nonce
// Step 2. Transfer value
fromAccount.balance -= tx.value
toAccount.balance += tx.value
// Step 3. Pay fee
fromAccount.balance -= tx.fee
feeRecipientAccount.balance += tx.fee
}
上述函数是BYOR中状态转换机制的核心。它基于一个假设,即交易可以被安全地执行,具有正确的nonce和足够的余额来完成所定义的支出操作。由于这个假设,该函数内部没有涉及错误处理或验证步骤。相反,这些步骤在调用该函数之前执行。每个账户的状态都被存储在一个映射中。如果某个账户在该映射中尚不存在,它将被设置为代码清单顶部可见的默认值。这三个账户分别进行nonce的更新和余额的分配。
交易签名
我们使用EIP-712标准来对类型化数据进行签名。这使我们能够清楚地向用户显示他们正在签名的内容。如上所示,当发送一笔交易时,我们可以以用户友好的方式显示接收者、金额和手续费。

L1 事件获取
function getNewStates() {
const lastBatchBlock = getLastBatchBlock()
const events = getLogs(lastBatchBlock)
const calldata = getCalldata(events)
const timestamps = getTimestamps(events)
const posters = getTransactionPosters(events)
updateLastFetchedBlock(lastBatchBlock)
return zip(posters, timestamps, calldata)
}
为了获取新的事件数据,我们在Inputs
合约中检索从上次获取的区块之后的所有BatchAppended
事件。我们的检索数量最多限制在最新区块数与上次获取的区块数加上批次大小之间。在检索所有事件后,我们从每个交易中提取calldata
、时间戳以及发布者地址等信息。然后,我们更新最后一个已获取的区块为当前正在获取的最后一个区块。接着,我们将提取到的calldata
、时间戳和发布者地址打包在一起,并从函数中返回,以供进一步处理和分析。
内存池及其费用排序
function popNHighestFee(txPool, n) {
txPool.sort((a, b) => b.fee - a.fee))
return txPool.splice(0, n)
}
内存池是一个管理已签名交易数组的对象。其中最引人注目的特点是它是如何决定哪个交易首先发布到L1的。正如上述的代码所示,这些交易是按照它们的手续费进行排序的。这使得系统中的中位数费用价格会随着链上活动而波动。
即使你指定了高手续费,如果这些交易需要被应用到当前状态,它们仍然必须产生一个有效的状态。因此,你不能只因为手续费高就提交无效的交易。
BYOR 是否真正扩展了以太坊?
乐观 Rollup和零知识(ZK)Rollup能够证明他们的数据是正确的,而主权 Rollup则没有这种机制。因此,主权 Rollup需要更多的计算来验证数据的准确性,这使得它不如其他Rollup类型能够更好地扩展以太坊。