构建你自己的Rollup

使用极简代码库,亲身实践构建一个最小功能集合的主权 Rollup

你是否曾经想要深入了解 Rollup 的运作原理?理论很好,但亲身实践经验总是更可取的。不幸的是,现有的项目并不总是让人轻易地查看内部情况。这就是为什么我们创建了 BYOR(Build Your Own Rollup:构建你自己的Rollup)。它是一个具有最小功能的主权 rollup,重点是使代码易于阅读和理解。

我们这个项目的动机是让人们(无论是外部人员还是内部人员)更好地理解我们周围的 rollup 实际上在做什么。你可以在 Holesky 的已部署的BYOR上玩耍,或者阅读GitHub 上的源代码

BYOR是什么?

BYOR 项目是一个简化版本的主权 rollup。与乐观和零知识证明的 rollup 相比,主权 rollup 不会在以太坊上验证状态根,只依赖于以太坊上的数据可用性和共识。这样可以防止 L1 和 BYOR 之间的信任最小化桥,但极大地简化了代码,非常适合教育目的。

代码库由三个程序组成:智能合约、节点和钱包。当它们一起部署时,它们允许最终用户与网络进行交互。有趣的是,网络的状态完全由链上数据确定,这意味着实际上可以运行多个节点。每个节点也可以作为排序器(Sequencer)独立地发布数据。

下面是 BYOR 中实现的完整功能列表:

  • 费用排序
  • 将状态发布到 L1 并从 L1 获取状态
  • 丢弃无效的交易
  • 查看账户余额
  • 发送交易
  • 查看交易状态

使用钱包

钱包应用中,它充当网络的前端,用户可以提交交易,并检查账户的状态或交易的状态。在登陆页面上,你会看到一个概览,其中提供了有关 rollup 当前状态的一些统计信息,然后是你的账户状态。很可能,这里仅有一个按钮用来连接你选择的钱包,并有关于代币水龙头的消息。在下面,有一个搜索栏,你可以粘贴某人的地址或交易哈希来探索 L2 的当前状态。最后,有两个交易列表:第一个是 L2 内存池中的交易列表,第二个是发布到 L1 的交易列表。

要开始,请使用 WalletConnect 按钮连接你的钱包。连接后,你可能会收到一个通知,提示你的钱包连接到了错误的网络。如果你的应用程序支持网络切换,请点击“切换网络”按钮切换到 Holesky 测试网络。否则,请手动切换。

现在,你可以通过提供接收者的地址、要发送的代币数量和所需手续费来向某人发送代币。发送后,钱包应用程序会提示你签署消息。如果成功签署,消息将被发送到 L2 节点的内存池中,等待被发布到 L1。交易被捆绑到批次发布中所需的时间可能会有所不同。每 10 秒,L2 节点会检查是否有待发布的内容。手续费较高的交易会优先发送,因此如果你指定了较低的手续费并且有大量交易流量,你可能会遇到较长的等待时间。

工作原理

Rollup 架构图

Rollup 架构图

技术栈

我们使用以下技术构建了每个组件:

  • 节点: Node.js, TypeScript, tRPC, Postgres, viem, drizzle-orm
  • 钱包: TypeScript, tRPC, Next.js, WalletConnect

代码深入解析

BYOR 代码专门设计成通过查看代码库就能轻松理解。请随意探索我们的代码库!首先阅读README.md,了解项目结构请阅读ARCHITECTURE.md文件。

以下是代码中的一些有趣亮点:

智能合约

// 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 NULL
 PRIMARY KEY(`from`, `nonce`)
);

-- This table has a single row
CREATE 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 是否真正扩展了以太坊?

乐观和 ZK rollup 已经建立了系统来证明发布的状态根与状态转换函数和它们提交的数据是一致的,但主权 rollup 没有。因此,这种类型的 rollup 无法扩展以太坊,这一点可能一开始看起来有些违反直觉。然而,当我们意识到其他类型的 rollup 可以仅使用 L1 来证明发布的状态根是正确的时,这就变得合理了。要区分主权 rollup 的数据是否正确,我们需要运行一个 L1 节点以及额外的软件,以形式化 L2 节点来执行状态转换函数,从而增加了计算负载。

未来展望

对我们来说,构建这个项目是一次很好的学习经验,我们希望你也会发现我们的努力有价值。我们希望将来能够回到 BYOR,为其添加一个欺诈证明系统。这将使它成为一个真正的乐观 rollup,并再次成为我们日常使用的系统内部工作方式的教训。与此同时,如果你想做出贡献,请随时对我们在GitHub 问题中描述的任何建议改进进行修改。

Twitter上关注我们,并加入我们的Discord以获取有关该项目的任何新进展的最新信息。


本翻译由 DeCert.me 协助支持, 在 DeCert 构建可信履历,为自己码一个未来。

点赞 1
收藏 3
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO