详解 Optimism Bedrock 系列 5 - 基本组件的角色和行为

本文是 Onther 发布的 Optimism Bedrock Wrap-Up 系列的第五篇,主要深入研究了 Optimism Rollup 中 Op-Batcher 和 Op-Proposer 的角色和行为逻辑。

Essential Optimism Bedrock Components 的角色和行为

Onther 旨在为当前对 Optimism 和以太坊生态系统的演变感兴趣的开发者提供有价值的信息。

图. Optimism 插图(来源: OP LABS))

这篇文章是“Optimism Bedrock Wrap-Up Series”的第五篇,该系列由 Onther 计划的五篇文章组成。在这里,我们全面研究 Op-Batcher 和 Op-Proposer 的角色和操作逻辑。

考虑到本系列的互连性质,我们建议按顺序阅读文章,以获得连贯的理解。

系列 1. Bedrock 升级概述:它提供了 Bedrock 升级、其组件以及在其层中部署的智能合约的概述。 Optimism Bedrock 总结系列 1

系列 2. Bedrock 升级以来的主要变化: 在本节中,我们的目标是阐明 Bedrock 升级引入的重大变化,为全面理解奠定基础,从而顺利阅读本系列接下来的部分。

Optimism Bedrock Wrap-Up Series 2 Bedrock 升级的主要变化

系列 3. 存款/取款流程分析: 我们将对存款/取款流程进行逐步分析,揭示其各层中的核心代码逻辑。

Optimism Bedrock Wrap Up Series 3 存款/取款流程

系列 4. 区块推导: 一旦在 Layer 2(Optimism 主网)上生成区块,系统就会启动一个过程,将这些区块 Roll-up 到 Layer 1。随后,在区块推导阶段,仅使用已 Roll-up 的数据重建 L2 区块。我们将提供详细的步骤指导,并在此过程中进行代码检查。

Optimism Bedrock Wrap-Up Series 4 分析区块推导流程

系列 5. Optimism Bedrock 组件的角色和行为: 作为本系列的最后一部分,我们将全面检查 Op-Batcher 和 Op-Proposer 的角色和操作逻辑。

Optimism Bedrock Wrap-Up Series 5 Optimism Bedrock 的基本组件的角色和行为

Optimistic Rollup 解决方案的基本组件

图. Optimism 先决条件架构

从提供的架构中可以看出,Optimism Rollup 目前依赖于四个基本元素:op-geth、op-node(rollup-node)、op-batcher 和 op-proposer。目前,这四个元素都以集中的方式运行,表现为一个统一的实体。然而,要理解整个机制,需要分别检查每个元素。它们应该被认为是独立实体的程度仍然不明确,并引发了许多疑问。

在以太坊等去中心化系统中,geth、sequencer、proposer、verifier 等实体之间存在着明确的权限和角色分离。我们预计 Optimism 的最终目标将与这种已建立的组件去中心化保持一致,并且他们正在努力实现这一目标。因此,我们将考虑上述四个元素作为自主实体,同时考虑到它们细微的关联和角色分配。然而,随着 Optimism 继续朝着更大的去中心化前进,每个元素的未来划分仍然不确定。

首先,让我们从每个元素的简明描述开始,然后再深入分析。

op-geth(L2 Geth)

它的职责是将来自 L1 和 L2 用户的交易纳入旧状态,以生成新状态。这个细致的过程通过监督整个交易处理过程中状态的存储和修改历史,来保护所有状态转换的完整性。

op-node(Rollup Node)

图. 各层组件之间的相关性(来源: Optimism)

在上图中,op-node 集中整合了不同的元素(sequencer、verifier、proposer)。然而,这些元素在每个过程中的各个角色都有明确的定义,并且

分解它们有助于更好地理解解决方案的整体机制。

首先,有一个 sequencer 负责生成 L2 区块。它通过标准以太坊 Engine API 将来自 L1 的 L2 交易的原始数据传递并转换为执行层(op-geth)来生成区块。此外,sequencer 还承担着 op-batcher 和 op-proposer 的任务,因此通常将它们视为 proposer 是有益的。

最后,虽然本系列未涵盖 verifier 的角色,但它会验证提交给 L1 的 batch 和 output 的 L2 链。(我们将在后续文本中进一步阐述每个元素的角色。)

op-batcher

Op-batcher,也称为 batch submitter 或 batcher,在将 L2 交易转换为 batch 然后将其写入 L1 的 BatchInbox 中起着至关重要的作用。在整个过程中,Op-node 会压缩 L2 交易,以优化数据传输并最大限度地减少内存使用。这种压缩有助于高效地推导 L2 链。

op-proposer

关于 op-proposer,它的主要职责是在 L1 中完成 L2 中发生的状态转换。实际上,为了使 rollup 解决方案能够在该过程中建立信任,L2 中的每个交易都必须在 L1 中经过验证和证明。任何未在 L1 中确认的 L2 交易实际上都被视为从未发生过。

在 op-geth 更新状态后,op-proposer 通过向 L1 提交该特定状态的授权来捕获修改后的状态。这种提交不仅仅是文档记录,它还同时为状态建议一个新的 Merkle root,以通过减少写入 L1 的数据来降低交易成本。随后,这些状态根的提案会被发布到 L1 的 L2OutputOracle,并且验证过程会在 7 天的最终确定期后进行。

在之前的系列四篇文章中深入研究了 op-geth 和 op-node 之后,后续的重点将放在 op-proposer 和 op-batcher 如何协同将 L2 状态和交易提交给 L1。

op-proposer 的角色和行为逻辑

如前所述,proposer 的角色包括将 L2 状态的 output root 提交给 L1 的 L2OutputOracle 合约。首先,让我们检查 output root 的配置。

图. specs/proposals.md (来源: github link)

  • version_byte:表示 output root 的版本,每当有结构性变化时,它都会被更新。
  • payload:任意长度的字节字符串,配置如上图所示。
  • state_root:所有执行层帐户的 Merkle-Patricia-Trie (MPT) root。
  • withdrawal_storage_rootMessagePasser 合约存储的 Merkle-Patricia-Trie (MPT) root。
  • lastest_block_hash:最新 L2 区块的区块哈希。

接下来,让我们探讨 L2 output 提交给 L1 的频率。

图. deploy-config/mainnet.json (来源: github link)

图. 收集 op-proposer 交易的状态 Batch 的一部分(来源: optimistic.etherscan.io)

L2 帐户的状态转换每 2 秒发生一次,与区块生成间隔一致。虽然每次发生状态转换都获取 output root 是理想的,但相关的成本使其过高。因此,至关重要的是要取得平衡,并以合理的间隔接收它。此周期由 deploy-config/mainnet.json 文件在节点初始部署期间配置,目前主网设置为 1,800 个区块,Goerli 设置为 120 个区块,Devnet 设置为 20 个区块。在主网上有 1,800 个区块的情况下,L2 output root 每 3,600 秒提交给 L1,相当于 1 小时。(这就是为什么在取款过程的第一步中,取款交易需要一个多小时才能提交到 L1 L2OutputOracle 合约的原因。)

查看上面的 optimistic.etherscan 屏幕截图,你可以观察到大约每小时发生的交易。详细信息包括存储它们的关联 L1 区块、L1 交易哈希和 output root。

现在,让我们继续分析代码。

CLIConfig

图. config.go/CLIConfig (来源: github link)

上面的代码用于配置 op-proposer。此配置结构有几个参数,如下所述。

必需参数

  • L1EthRpc:L1 HTTP 提供程序的 URL。
  • RollupRpc:Rollup-node HTTP 提供程序的 URL。
  • L2OOAddressL2OutputOracle 合约的地址。

图. flags.go/PollIntervalFlag (来源: github link)

  • PollInterval:查询 L2 区块(交易)以生成 output root 的频率设置为 6 秒,如上所示。鉴于每 2 秒创建一个 L2 区块,因此同时查询 3 个区块。此过程累积 1,800 个 L2 区块,随后将其提交给 L1。
  • AllowNonFinalized:一个布尔标志,设置为 true 以允许对从尚未最终确定的 L1 交易导出的 L2 区块的 output 提出 proposal。
  • TxMgrConfig:用于交易管理的结构。

可选参数

  • RPCConfig:用于远程过程调用的结构。
  • LogConfig:用于在 Proposer 中配置日志记录的结构。
  • MetricsConfig:用于配置 Proposer 指标的结构。
  • PprofConfig:pprof 是一种用于分析 go 应用程序数据的工具,你可以跟踪所需目标的 cpu、内存、跟踪等。这很容易使用,因为 go tools 具有对 pprof 的内置支持。

Main

图. L2_output_submitter.go/Main (来源: github link)

Main 函数是 L2 output 提交者(proposer)服务的入口点,接受两个参数:versioncli.Context。version 参数指定服务版本,cli.Context 表示在运行时传递给服务的命令行参数。现在,让我们深入更详细地检查该函数。

  • flags.CheckRequired:验证先前提到的必需标志是否已设置,如果缺少任何标志则触发错误。
  • NewConfig:用于在 cli.Context 中创建一个新的 Config 对象,封装先前设置的所有配置(标志)信息。
  • oplog.NewLogger:启动一个新的 logger,使用 oplog.SetGlobalLogHandler 函数将其建立为全局日志处理程序。
  • metrics.NewMetrics:生成一个新的 metrics 对象。
  • NewL2OutputSubmitterConfigFromCLIConfig:从 Config 对象构建一个新的 L2OutputSubmitterConfig 对象,以初始化 L2 output proposal。
  • NewL2OutputSubmitter:从 L2OutputSubmitterConfig 对象创建一个新的 L2OutputSubmitter 对象。
  • L2OutputSubmitter:使用此对象的 Start 方法启动 L2 output 提交者。
  • defer:确保在函数结束后,L2 output 提交者也终止。

至此,我们完成了新提交者的创建。让我们继续执行负责运行 pprof、metrics 和 rpc 服务器的代码。

图. L2_output_submitter.go/Main (来源: github link)

最初,使用 prof.StartServer 函数启动 pprof 服务器,捕获服务的数据,例如 CPU、内存和跟踪信息。此外,使用 defer 语句来保证在函数结束时,pprof 服务器也会终止。

图. L2_output_submitter.go/Main (来源: github link)

随后,使用 m.Start 函数启动 metrics 服务器,并记录一条消息,表明其已启动。同样,使用 defer 语句来确保 metrics 服务器与函数一起终止。最后,调用 m.StartBalanceMetrics 来跟踪 op-proposer 以太坊帐户的余额。这至关重要,因为将 output 发布到 L2OutputOracle 合约会产生 gas 费用,需要有足够的余额才能成功执行。

图. L2_output_submitter.go/Main (来源: github link)

Main 函数的结尾部分涉及通过 oprpc.NewServer 函数创建一个新的 RPC 服务器,并合并 admin API。继续使用 Start 方法启动 RPC 服务器,并记录一条消息来确认服务器的启动。

Start(loop) / Stop(loop)

图. L2_output_submitter.go/Start(), Stop() (来源: github link)

在 proposal 过程中,“loop”方法充当一个事件循环,持续查询新的 L2 交易,以便将其传输回 L1。Start() 方法协调此循环的执行,接收 L2OutputSubmitter 提交的 output。

随后,Stop() 方法负责取消 loop 方法使用的 context,关闭“done”通道,并通过 wg.Wait() 方法等待 loop 方法的结束,从而有效地停止 L2 output 提交者。

现在,让我们深入了解 loop 方法的详细信息。

loop()

图. L2_output_submitter.go/loop (来源: github link)

loop”方法具有持续检索下一个 output 信息的重要任务。它评估是否提出 output,调用并记录负责将提出的 output 传输到 L1 的方法。

首先,L2OutputSubmitter 结构的“pollInterval”字段中的 ticker 以固定的时间间隔触发,执行 select 语句的第一个 case。执行后,调用 FetchNextOutputInfo 方法来获取要提交的下一个 output。此方法返回一个布尔值,指示获取的 output 是否处于 proposal 就绪状态。如果是,则返回一个表示错误的 OutputInfo 对象。如果遇到错误,则会中断循环,并且只能在重新触发 ticker 后才能恢复。

相反,如果 output 适合提交,则使用 context.WithTimeout 创建一个超时时间为 10 分钟的新 context。随后,调用 sendTransaction 方法将 output 提交给 L1。如果交易成功,则在 metrics 对象上调用 RecordL2BlocksProposed 方法以记录提出的区块。

虽然我们简要概述了“ loop() ”方法及其整体流程,但让我们深入了解对 FetchNextOutputInfo sendTransaction 方法的特定调用。

FetchNextOutputInfo

图. L2_output_submitter.go/FetchNextOutputInfo (来源: github link)

FetchNextOutputInfo 方法在 loop() 中调用,它采用 context.Context 对象作为参数,并利用它来设置执行的请求的超时时间。

首先使用 context.WithTimeout 方法创建一个新的 context.Context 对象,并将超时设置为 l.networkTimeout。随后,生成一个 bind.CallOpts 对象,其中 From 字段配置为 txMgr 对象的 From 地址,Context 字段设置为此新形成的 context。

然后,callOpts 对象用于调用 L2 output 合约的 NextBlockNumber 方法,检索下一个检查点区块编号。在此之后,使用 context.WithTimeout 函数创建另一个 context,将超时设置为 networkTimeout,这次请求有关当前正在处理的 L2 区块头的信息。

调用 rollupClient.SyncStatus 以获取与 L2 的当前同步状态,并使用 context.Context 作为参数。此时,currentBlockNumber 变量被分配最新的 L2 区块编号。

随后,根据 L2OutputSubmitterallowNonFinalized 字段设置安全头。如果为 true,则使用安全头;如果为 false,则使用最终确定的头。然后,L2OutputSubmitter 检查当前区块编号是否低于下一个检查点区块编号。如果是,则确定现在提出 proposal 还为时过早,并且该方法返回 nil。相反,如果当前区块编号大于或等于下一个检查点区块编号,则调用 fetchOutput 方法来检索要提交的 output。

总之,此函数可以理解为一个序列,包括获取下一个 proposal 的区块编号、根据 L2 的当前状态决定是否进行 proposal 以及获取相应的 output。

sendTransaction

图. L2_output_submitter.go/sendTransaction (来源: github link)

proposal 过程中的最后一步涉及 sendTransaction 方法,该方法负责将 output root 分派给 L1。此方法采用 context.Contexteth.OutputResponse 对象作为参数,其中 eth.OutputResponse 封装了要提交的 L2 output 交易信息。

首先,它调用 waitForL1Head 方法,确保将 L2 output 交易提交给指定的 L1 区块的正确顺序。然后,它调用 ProposeL2OutputTxData 来生成 L2 output 交易数据,将其转换为字节数组,并在 eth.OutputResponse 中返回它。

转换完成后,调用 txMgr.Send 方法将交易数据传输到 L1。此方法采用 txmgr.TxCandidate 对象作为参数,其中包含交易数据、用于交易提交的合约地址 (L2OutputOracle) 以及交易的 gas 限制。

交易成功后,将检查交易回执的状态以验证其是否成功。随后,将记录交易哈希、L1 区块编号和 L1 区块哈希。

现在,让我们更深入地研究在此方法中调用的 proposeL2OutputTxData 函数。

proposeL2OutputTxData

图. L2_output_submitter.go/proposeL2OutputTxData (来源: github link), L2OutputOracle/proposeL2Output, emit outputProposed (来源: github link),

proposeL2OutputTxData 函数首先使用 abi.Pack 方法打包(将多个值合并为一个变量)L2OutputOracle 合约的 proposeL2Output 函数的参数。打包的必要数据是从提供的 eth.OutputResponse 中提取的。

随后,调用 L1 的 L2OutputOracle 合约的 proposeL2Output 函数,接收以下四个参数:

  • _outputRoot:L2 区块的 output root。
  • _l2BlockNumber:生成 output root 的 L2 区块编号。
  • _l1BlockHash:当前正在处理的 L1 区块哈希。
  • _l1BlockNumber:上述区块哈希的区块编号。

检查 emit OutputProposed,它采用四个参数:

  • nextOutputIndex(): 下一个 output 的索引。
  • _l2BlockNumber:生成 output root 的 L2 区块编号。
  • Block.timestamp:当前 L1 区块的时间戳。

为了存储有关提出的 L2 output 交易的信息,会生成一个新的 Types.OutputProposal 结构。此结构推送 output root、该区块的时间戳以及 L2 output 交易的区块编号。

到目前为止,我们已经探索了 op-proposer 如何将 L2 output 提交给 L1 上的 L2OutputOracle 。我们现在将继续描述 op-batcher。

op-batcher 的角色和行为逻辑

图. 要提交给 L1 的 BatchInbox 的 L2 区块的数据转换过程

在 Bedrock 升级之前的旧版本中,每个 L2 交易都会生成一个区块。然后,所有 L2 区块都会提交给 CTC(合规承诺)合约,从而在 batch-submitter 的轮询间隔将多个交易合并到一个 batch 中。但是,通过 Bedrock 升级,该方法已发展为将多个 batch 合并到一个统一通道中进行提交,以最大限度地减少数据可用性成本。

BatchInbox

batcher 创建的交易被定向到 L1 上名为 BatchInbox 的特殊 EOA(外部所有帐户)。由于 BatchInbox 是一个 EOA 地址,因此不会执行 EVM 代码,从而节省了 gas 成本。

现在,让我们继续检查 op-batcher 代码的实现。我们将首先概述更广泛的概念,然后再探索详细方法中的特定逻辑。

loop()

图. driver.go/loop() (来源: github link)

提供的代码封装了 batcher 的 loop() 方法,该方法设计用于持续迭代以检索数据,以便将 L2 区块转换为通道。

在这种情况下,两个关键函数发挥作用:loadBlocksIntoStatepublishStateToL1loadBlocksIntoState 方法的任务是将新检测到的 L2 区块集成到本地状态中,为提交做好准备。另一方面,publishStateToL1 方法协调将加载的区块转换为通道帧,最终构造一个 batcher 交易以提交给 L1 BatchInbox

此过程由 select 语句控制,该语句等待接收三个事件之一:来自 tickertick、来自 receiptsCh收据 以及来自 shutdownCtx信号。收到 tick 后,通过调用 loadBlocksIntoState 方法将来自 L2 的最新区块加载到状态中,并使用 publishStateToL1 函数将其提交给 L1。

让我们继续更详细地检查 loadBlocksIntoState publishStateToL1 方法。

loadBlocksIntoState

图. driver.go/loadBlocksIntoState (来源: github link)

loadBlocksIntoState 方法首先调用 calculateL2BlockRangeToStore 函数,该函数负责确定要存储在状态中的 L2 区块的范围(开始、结束)。此函数采用 context.Context 对象作为参数,并返回两个表示该范围的 eth.BlockID 对象。它同步自上次提交的 batch 以来 L2 链中的所有区块信息。

图. driver.go/loadBlocksIntoState (来源: github link)

然后,启动一个循环,调用 loadBlockIntoState 方法来加载通道管理器的状态,该通道管理器负责管理指定范围内的 op-batcher 的通道。如果遇到 L2 重组错误,则会进行记录,并且作为一项预防措施,lastStoredBlock 变量会重置为一个空的 eth.BlockID。此操作可防止将状态提交给 L1,从而减轻与 L2 重组错误相关的潜在复杂情况。相反,如果没有错误,则更新 lastStoredBlocklatestBlock 变量以与加载的状态对齐。

图. driver.go/loadBlocksIntoState (来源: github link)

将所有相应的区块加载到通道管理器的状态后,该方法会调用 derive.L2BlockToBlockRef 函数。此调用用于存储对启动 rollup 链的最新区块和创世区块的引用。为了完成其操作,该方法使用 Metr.RecordL2BlocksLoaded 记录加载到状态中的 L2 区块数。

publishStateToL1

总之,publishStateToL1 方法封装了获取存储在通道管理器状态中的区块信息的过程,将其转换为通道帧,并将其返回到 loop() 方法。

图. driver.go/publishStateToL1 (来源: github link)

为了启动此过程,会创建一个名为 txDone 的新通道,以指示所有状态加载完成。随后,启动一个新的 goroutine(由 Go 运行时管理的轻量级线程)以发送状态并等待将其传输到 L1。在此 goroutine 中,使用一个循环结构,为队列中的每个交易调用 publishTxToL1。此函数负责将 L2 区块信息转换为 batcher 交易并将其传输到 L1。

从此方法转换为 batcher 交易的逻辑围绕 publishTxTo TxData 函数展开,我们接下来将对其进行检查。

publishTxToL1

图. driver.go/publishTxToL1 (来源: github link)

首先,调用 l.l1Tip 方法来检索最新的 L1 区块头。然后调用 recordL1Tip 方法来记录 L1 tip,也称为优先级费用。在此之后,利用本地状态的 TxData 方法来获取下一个 batcher 交易。如果没有要检索的交易数据,则会记录一条跟踪日志,并返回一个 io.EOF 错误。

TxData 函数通过协调创建包含 L2 区块信息的 batch,在整个过程中发挥着关键作用。它的职责包括编码和压缩此信息、将其存入通道,然后将生成的通道划分为通道帧。这个全面的过程最终形成了 batcher 交易,标志着整个过程中的关键一步。下面将提供有关此关键函数的更多详细信息。

随后,调用 sendTransaction 方法将交易数据分派给 L1。此方法需要 dataqueuereceiptsCh 对象作为参数。它在将 batch 发送到 L1 中起着至关重要的作用,并且将在下面进行更详细的阐述。

TxData

图. channel_manager.go/TxData (来源: github link)

最初,TxData 函数扫描 channelQueue 以识别第一个具有可提交帧的通道。如果找到这样的通道,则相应地设置变量 firstWithFrame。在此之后,它会检查是否存在挂起的数据或 channelManager 对象的关闭。如果满足任一条件,则调用 nextTxData 方法来检索后续交易数据。相反,在没有挂起数据的情况下,该函数会检查是否有可用的存储区块来构建帧。然后调用 ensureChannelWithSpace 方法来确定是否存在有足够的空间可以提交给 L1 的通道。

如果找到合适的通道,则调用 processBlocks 方法来使用存储的区块填充通道。然后调用 registerL1Block 方法来注册当前的 L1 head。

完成这些步骤后,使用 outputFrames 方法来生成帧,然后将其添加到帧队列。最后,使用 nextTxData 方法从帧队列中收集通道帧,并形成一个 batcher 交易,以便随后检索。

接下来,我们将深入研究 processBlocks 方法,以了解它如何使用存储的区块填充通道。

processBlocks

图. channel_manager.go/processBlocks (来源: github link)

在此部分中,建立一个循环以单独处理存储在区块切片中的每个区块,当通道达到最大容量时终止。然后,使用 AddBlock 方法将切片中的每个存储区块附加到通道。

成功将所有区块添加到通道后,blocksAdded 变量会递增 首先,让我们开始使用 core.IntrinsicGas 方法离线估算 gas 费用。一旦我们有了这个估算值,我们就可以创建一个 txmgr.TxCandidate 对象,该对象封装了交易数据、内在 gas 限制和 BatchInbox 地址。最后,我们通过使用 Send 函数生成交易来结束该过程。此交易会将先前创建的 batcher 交易发送到 Layer 1 (L1) BatchInbox

结束语

随着第五部分的结束,Bedrock Wrap Up 系列已经完结。探索引入的新术语和概念的复杂性,包括 op-stack、op-proposer、op-batcher、sequencer、verifier、derivation、sequencing window 等,带来一定的复杂性,而且相同对象的命名有时会有所不同。对每个术语进行有条不紊地解构,并结合对代码级别逻辑和关系的清晰解释,证明是至关重要的。尽管偶尔会出现术语重叠,但记录各种关系能够理清研究方向,从而促进更全面的理解。

我们期望这五部分的研究对全球区块链研究人员有价值,并且我们承诺继续探索和分享 Layer 2 技术领域的发现。谢谢。

通过两步提款增加对 OP Mainnet 桥的信心

optimism/specs/overview.md at develop · ethereum-optimism/optimism

optimism/specs/proposals.md at master · ethereum-optimism/optimism

optimism/packages/contracts-bedrock/deploy-config/mainnet.json at develop ·…

OP Mainnet 区块 #109880905 | Optimism

**optimism/op-proposer/proposer/config.go at develop · ethereum-optimism/optimism

**optimism/op-proposer/flags/flags.go at develop · ethereum-optimism/optimism

**GitHub - google/pprof: pprof 是用于可视化和分析分析数据的工具

optimism/op-proposer/proposer/l2_output_submitter.go at develop · ethereum-optimism/optimism

optimism/packages/contracts-bedrock/src/L1/L2OutputOracle.sol at develop

optimism/op-batcher/batcher/driver.go at develop · ethereum-optimism/optimism

地址 0xff00000000000000000000000000000000000420 | Etherscan

**Null: 0xFF0...010 | 地址 0xFF00000000000000000000000000000000000010

optimism/op-batcher/batcher/channel_manager.go at develop · ethereum-optimism/optimism

  • 原文链接: medium.com/tokamak-netwo...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
aaronlee94
aaronlee94
江湖只有他的大名,没有他的介绍。