Compilation of the original text: The Way of DeFi
Compilation of the original text: The Way of DeFi
as well asOptimism Bedrockas well asArbitrum NitroAnalysis of design differences between articles.
It's all about meNitro white paperreading, and my perceptual knowledge of Bedrock's design.
This gets very technical, and if you want to pay attention and get confused, I suggest you refer to the Bedrock overview and my article onPresentation of Cannon's failure proof system, and of course the Nitro white paper.
When you're ready, let's get started!
First of all, the Nitro whitepaper is great, a joy to read, and I recommend anyone interested to check it out.
Having said that, my impression is that Bedrock and Nitro use roughly the same architecture, with some minor differences.
The white paper largely confirms this. Still, there are plenty of differences, including some I wasn't expecting. That's what this article is about.
(A) Fixed vs variable block times
One of the most interesting and important things is that Nitro will work like the current version of Optimism with one block per transaction and variable time between blocks.
We dropped this as it deviates from the way Ethereum works and is a pain point for developers. Whereas Bedrock will have "real" blocks and a fixed time of 2 seconds.
Irregular block times make many common contracts unstable because they use blocks instead of timestamps to represent time. This notably includes the Masterchef contract originating from Sushiswap that distributes LP rewards.
I'm not sure why these contracts represent time in blocks instead of timestamps! Ethereum miners have some leeway in manipulating timestamps, but by default clients don't build blocks that are too far from the wallclock (15 seconds for Geth), so there's no problem.
Anyway, on Optimism, this caused StargateFinance rewards to run out months earlier than other chains because they didn't account for this specificity!
The "block per transaction" model has other problems. First, storing the chain is expensive (one block header per transaction). Second, this means that the state root needs to be updated after every transaction.
Updating the state root is a very expensive operation, the cost of which is amortized across multiple tx.
(B) Geth as a library or as an execution engine
Nitro uses Geth "as a library", with minimal modifications to it via hooks to call the appropriate functions.
In Bedrock, a minimally modified Geth runs independently as an "execution engine" that receives instructions from rollup nodes in the same way that the execution layer receives instructions from the consensus layer in Eth2. We even use the exact same API!
This has some important implications. First, we were able to use other clients than Geth, applying similar minimal differences on top of them. It's not just theory, we're readyErigon。
Second, this allows us to reuse the entire Geth (or other client) stack, including at the network layer, which enables features like peer discovery and state synchronization without any additional development effort.
(B) State storage
Nitro keeps some state ("ArbOS state") in a special account (which itself is stored in Arbitrum's chain state), using a special memory layout that maps keys to storage slots.
(This is purely architectural and has no impact on users.)
In that sense, Bedrock doesn't have much state, it has very little state stored in plain EVM contracts (to be fair, you could implement ArbOS state layout using the EVM, but I don't think they do it that way) .
When determining/executing the next L2 block, a Bedrock replica looks at:
The block header at the head of the L2 chain;
Data read from L1;
For some data in the EVM contract on the L2 chain, there are currently only L1 fee parameters;
In Bedrock, nodes can crash and restart gracefully immediately. They do not need to maintain additional databases, as all necessary information can be found in L1 and L2 blocks. I think Nitro works the same way (architecture makes this possible).
But it's clear that Nitro did more bookkeeping than Bedrock.
(C) L1 to L2 message contains delay
Nitro processes L1 to L2 messages (which we call "deposit transactions" or simply "deposits") with a 10-minute delay. On Bedrock, you should usually have a small confirmation depth of a few blocks (maybe 10 L1 blocks, so about 2 minutes).
We also have a parameter called "sequencer drift" which allows the timestamp of an L2 block to drift ahead of its L1 origin (the L1 block marks the end of the range of L1 blocks from which batches and deposits are made) Derived).
We still need to determine the final value, but we are also leaning towards 10 minutes, which means that the worst case is 10 minutes. However, this parameter is intended to ensure the activity of the L2 chain during the temporary loss of the connection with L1.
However, usually the deposit is included immediately after the depth is confirmed.
As mentioned in Nitro's white paper, this 10-minute delay is to avoid deposits on L1 from disappearing due to reorganization. This got me curious about an aspect that the whitepaper didn't touch on: how the L2 chain handles L1 reorganizations. I think the answer is that it doesn't handle it.
This is not unreasonable: after the merge, the finality latency of L1 is about 12 minutes. So if a deposit delay of 10/12 minutes is acceptable, then this design is fine.
Because Bedrock is closer to L1, we need to handle L1 recombination by recombining L2 when needed. Confirmation depth should prevent this from happening too often.
Another small difference is that if the Nitro sorter doesn't include the deposit after 10 minutes, you can "force include" it via the L1 contract call.
On Bedrock, this is not required: a deposit with an L2 block that does not include its L1 origin is invalid.
And since L2 can only be 10 minutes ahead of origin (sequencer drift), a chain that is not deposited after 10 minutes is invalid and will be rejected by validators and challenged by a proof-of-failure mechanism.
(D) L1-to-L2 message retry mechanism
Nitro implements a "retryable tickets" mechanism for L1 to L2 messages. Assuming you're cross-chaining, the L1 part of the tx works (locking your tokens), but the L2 part might fail. Therefore, you need to be able to retry the L2 part (possibly with more gas), otherwise you've lost coins.
Nitro implements this in the ArbOS part of the node. In Bedrock, this is all done in Solidity itself.
If you send a tx to L2 using our L1 cross-domain messenger contract, that tx will reach our L2 cross-domain messenger, which will record its hash, making it retryable. Nitro works the same way, just implemented in Node.
We also expose a lower level deposit method through our L1 Optimism Portal contract.
This doesn't give you the safety net of an L2 cross-origin messenger retry mechanism, but on the other hand, it means you can implement your own application specific retry mechanism in Solidity. That's cool!
(E) L2 Fee Algorithm
On both Bedrock and Nitro systems, the fee has an L2 part (execution gas, similar to Ethereum) and an L1 part (the cost of L1 calldata). For L2 fees, Nitro used a custom system, while Bedrock reused EIP-1559. Nitro has to do this because they have the 1 tx/block system mentioned above.
We still need to tune the EIP-1559 parameters to make it work properly with a 2 second block time. Today, Optimism only charges low and fixed L2 fees, and I thought we might see price spikes as well, but in practice that never happened.
One advantage of reusing EIP-1559 is that it should make it slightly easier for wallets and other tools to calculate fees.
While Nitro's gas metering formula is very elegant, they seem to have put a lot of thought into it.
(F) L1 Fee Algorithm
What about the L1 fee? Here the difference is a bit bigger. Bedrock uses backward-looking L1 base cost data. This data is very fresh as it is passed through the same mechanism as deposits (i.e. almost instantly).
Since there is still a risk of L1 fee spikes, we charge a small multiple of the expected fee.
Fun fact: this multiple (which we have lowered several times since launching the chain) is where all current sorter revenue comes from! With EIP-4844 this will shrink and revenue will come from MEV extraction.
What Nitro does is much more complex. I don't claim to understand all of its intricacies, but the basic gist is that they have a control system that gets feedback from what L1 actually pays.
This means using this data to send transactions from L1 back to L2. If the orderer is underpaid, it can start charging users less. If it overpays, it can start charging users more.
By the way, you might wonder why we need to transfer cost data from L1 to L2. This is because we want the fee plan to be part of the protocol and accept the challenge of proof of failure. Otherwise, rogue orderers can reject the chain by setting arbitrarily high fees!
Finally, transaction batches are compressed in both systems. Nitro charges L1 fees based on an estimate of transaction compression. Bedrock is not currently doing this, but we have plans to do so.
The reason is that not doing so reinforces the perverse incentive to cache data in L2 storage, leading to problematic state growth.
(G) Failure proof instruction set
Fault/fraud proof! There are quite a few differences between how Nitro works and how Cannon (the failsafe system we're currently implementing on top of Bedrock) works.
Bedrock compiles to the MIPS instruction set architecture (ISA), and Nitro compiles to WASM. They seem to be doing more transformations on the output due to compiling to a subset of WASM they call WAVM.
For example, they replace floating-point (FP) operations with library calls. I suspect they don't want to implement crude FP operations in the on-chain interpreter. We do that too, but the Go compiler handles it for us!
Another example: Unlike most ISAs that only have jumps, WASM has proper (possibly nested) control flow (if-else, while, etc.). The conversion from WASM to WAVM removes this to jump back, probably also for simplicity of the interpreter.
They also compile a mix of Go, C and Rust to WAVM (in different "modules"), whereas we only compile Go. Apparently WAVM allows "language memory management to be uninterrupted", which I interpret as meaning that each WAVM module has its own heap.
What I'm curious about is: how do they handle concurrency and garbage collection. We were able to avoid concurrency fairly easily in minigeth (our stripped-down geth), so this part is probably easy (more on how Bedrock and Nitro use geth at the end of this article).
However, one of the only conversions we made to MIPS was patching garbage collection calls. This is because garbage collection uses concurrency in Go, and concurrency and failure proof don't go well together. Did Nitro do the same?
(H) Dichotomous game structure
The Bedrock fault proof will be used to verify the minigeth run of the validity of the state roots (actually the output roots) posted to L1. Such state roots are published infrequently and include validation of many blocks/batches.
The binary game in Cannon is played on this (long-running) execution trajectory.
In Nitro, on the other hand, a state root is published with each set of batches (RBlocks) published to L1.
The binary game in Nitro is divided into two parts. Start by finding the first state root that the challenger and defender disagree on. Then, find the first WAVM instruction in the validator run that they disagree with (it only validates a single tx).
The trade-off is more hashing during Nitro execution (see part (A) above), but less hashing during failure proof: at each step in the binary game of execution trace, Both need to submit the memory Merkle root.
A failure-proof structure like this also reduces concerns about validator memory bloat, which could exceed the current 4G memory limit of running MIPS.
This is not a hard problem to solve, but we need to be careful in Bedrock, and verifying a single transaction will probably never come close to this limit.
(i) Preimage oracle
The verifier software for failure proof needs to read data from L1 and L2. Because it will eventually "run" on L1 (albeit with only one instruction), L2 itself needs to be accessed through L1 - through state roots and block hashes posted to L1.
How do you read from state or chain (be it L1 or L2)?
A Merkle root node is a hash of its children, so if you can request a preimage, you can traverse the entire state tree. Likewise, you can traverse the entire chain backwards by requesting the preimage of the block header. (Each block header contains the hash of its parent block.)
These preimages can be pre-feed to the WAVM/MIPS interpreter when executing on-chain. (When executing off-chain, the L2 state can be read directly!)
(Note that you only need to access one such preimage, since you're only executing one instruction on-chain!)
This is how you read L2 on Nitro and Bedrock.
However, you need to do something similar for L1. Because transaction batches are stored in L1 call data, which cannot be accessed from L1 smart contracts.
Nitro stores the hash of their batches in the L1 contract (this is why their "Sequencer Inbox" is a contract, not an EOA like Bedrock). So they need to do that at least, I don't know why it wasn't mentioned.
In Bedrock, we don't even store batch hashes (thus saving some gas). Instead, we use the L1 block header to go back up the L1 chain, then walk down the transaction Merkle root to find the batch in calldata.
(Again, on-chain, at most one preimage needs to be provided.)
At the end of Section 4.1, remind us thatArbitrum invented the "hash oracle trick". Insecurity should not be a reason to forget the contributions of the Arbitrum team!
(J) Large preimages
The Nitro white paper also tells us that the L2 Preimage has a fixed upper limit of 110 kb, but doesn't quote the L1 figure.
In Cannon, we have a problem called the "big preimage problem" because one of the potential preimages to reverse is the receipt preimage, which contains all data emitted by Solidity events ("logs" at the EVM level).
In a receipt, all log data is concatenated. This means an attacker can emit a lot of logs and create a very large preimage.
We need to read the logs because we use them to store deposits (L2-to-L1 messages). This isn't strictly necessary: Nitro avoids this problem by storing a hash of the message (it's more complicated than that, but the end result is the same).
We don't store the hash because it is expensive to compute and store, about 20k gas for storage and 6 gas for every 32 bytes computed. An average transaction is about 500 bytes, so the hash cost for a batch of 200 transactions is about 20k gas. At $2000 in ETH and a basefee of 40 gwei, the additional hashing and storage costs $3.2. At $5000 in ETH and 100 gwei, the cost is $20.
Our current plan to solve the large preimage problem is to use a simple zk-proof to prove the value of certain bytes in the preimage (since that's all an instruction needs to access in practice).
(K) Batch and state root
Nitro tightly ties batches and state roots together. They publish a set of batches in the RBlock containing the state root.
Bedrock, on the other hand, publishes its batches separately from the state root. The key advantage is again reducing the cost of issuing batches (no need to interact with contracts or store data). This allows us to release batches more frequently and reduce the frequency of state roots.
Another implication is that with Nitro, if an RBlock is challenged, the transactions it contains will not be replayed on the new chain (new correct state root).
In Bedrock, we are currently discussing what to do in the event of a successful challenge to the state root: replay the old tx on the new state root, or roll back entirely? (The current implementation implies a full rollback, but that may change before rolling out proof-of-failure.)
(L) Miscellaneous
Smaller impact differences:
(i) Nitro allows a single transaction issued by an orderer to be "garbage" (invalid signature, etc.). To minimize changes to Geth, we always discard batches that contain any spam transactions.
The sorter will always be able to find those ahead of time, so lingering junk transactions are either misconduct or bugs. The sorter runs the same code as the failure proof, so their definition of what is invalid should be the same.
(ii) Nitro introduces precompiled contracts, especially for L2 to L1 message passing. We currently don't use any precompilations, preferring them to be "predeployed", the actual EVM contracts that live at a special address in the genesis block.
It turns out that we can do what we need in the EVM, which makes the node logic slightly simpler. However, we are not firmly opposed to precompilation, maybe we will need to use precompilation at some point.
(iii) The Nitro failure proof uses d-way dissection. The proof-of-concept Cannon implementation uses dichotomy, but we may also move to d-direction profiling.
end
end
Original link
