Orbit stack
Table of Contents
Sequencing
The main function used to sequence blobs in the orbit stack is the addSequencerFromBlobsImpl
function, whose interface is as follows:
function addSequencerL2BatchFromBlobsImpl(
uint256 sequenceNumber,
uint256 afterDelayedMessagesRead,
uint256 prevMessageCount,
uint256 newMessageCount
) internal {
Example of calls:
-
Block:
22866981
(link)sequenceNumber
:934316
afterDelayedMessagesRead
:2032069
prevMessageCount
:332968910
newMessageCount
:332969371
(+461
)
-
Block:
22866990
(+9
) (link)sequenceNumber
:934317
(+1
)afterDelayedMessagesRead
:2032073
(+4
)prevMessageCount
:332969371
(+0
)newMessageCount
:332969899
(+528
)
-
Block:
22867001
(+11
) (link)sequenceNumber
:934318
(+1
)afterDelayedMessagesRead
:2032073
(+0
)prevMessageCount
:332969899
(+0
)newMessageCount
:332970398
(+499
)
It's important to note that when a batch is submitted, also a "batch spending report" is submitted with the purpose of reimbursing the batch poster on the L2. The function will be analyzed later on.
The formBlobDataHash
function is called to prepare the data that is then saved in storage. Its interface is as follows:
function formBlobDataHash(
uint256 afterDelayedMessagesRead
) internal view virtual returns (bytes32, IBridge.TimeBounds memory, uint256)
First, the function fetches the blob hashes of the current transaction using a Reader4844 yul contract. Then it creates a "packed header" using the packHeader
function, which is defined as follows:
function packHeader(
uint256 afterDelayedMessagesRead
) internal view returns (bytes memory, IBridge.TimeBounds memory) {
The function takes the rollup's bridge "time bounds" and computes the appropriate bounds given the maxTimeVariation
values, the current timestamp and block number. Such values are then returned together with the afterDelayedMessagesRead
value.
A time bounds struct is defined as follows:
struct TimeBounds {
uint64 minTimestamp;
uint64 maxTimestamp;
uint64 minBlockNumber;
uint64 maxBlockNumber;
}
and the maxTimeVariation
is a set of four values representing how much in the past or in the future the time or blocks can be from the current time and block number. This is done to prevent reorgs from invalidating sequencer preconfirmations, while also establishing some bounds.
For Arbitrum One, these values are set to:
delayBlocks
:7200
blocks (24 hours at 12s block time)futureBlocks
:64
blocks (12.8 minutes at 12s block time)delaySeconds
:86400
seconds (24 hours)futureSeconds
:768
seconds (12.8 minutes)
The formBlobDataHash
function then computes the blobs cost by taking the current blob base fee, the (fixed) amount of gas used per blob and the number of blobs. Right after, the following value is returned:
return (
keccak256(bytes.concat(header, DATA_BLOB_HEADER_FLAG, abi.encodePacked(dataHashes))),
timeBounds,
block.basefee > 0 ? blobCost / block.basefee : 0
);
Now that the dataHash
is computed, the addSequencerL2BatchImpl
function is called, which is defined as follows:
function addSequencerL2BatchImpl(
bytes32 dataHash,
uint256 afterDelayedMessagesRead,
uint256 calldataLengthPosted,
uint256 prevMessageCount,
uint256 newMessageCount
)
internal
returns (uint256 seqMessageIndex, bytes32 beforeAcc, bytes32 delayedAcc, bytes32 acc)
The function, after some checks, calls the enqueueSequencerMessage
on the Bridge
contract, passing the dataHash
, afterDelayedMessagesRead
, prevMessageCount
and newMessageCount
values.
The enqueueSequencerMessage
function is defined as follows:
function enqueueSequencerMessage(
bytes32 dataHash,
uint256 afterDelayedMessagesRead,
uint256 prevMessageCount,
uint256 newMessageCount
)
external
onlySequencerInbox
returns (uint256 seqMessageIndex, bytes32 beforeAcc, bytes32 delayedAcc, bytes32 acc)
The function, after some checks, fetches the previous "accumulated" hash and merges it with the new dataHash
and the "delayed inbox" accumulated hash given the current afterDelayedMessagesRead
. This new hash is then pushed in the sequencerInboxAccs
array, which represents the canonical list of inputs to the rollup state transition function.
Where are these inputs used?
- When creating a new assertion, to ensure that the caller is creating an assertion on the expected inputs;
- When "fast confirming" assertions (in the context of AnyTrust);
- When validating the inputs in the last step of a fraud proof, specifically inside the
OneStepProverHostIo
contract.
Batch spending report
TODO
Proving
State roots are proved using an optimistic proof system involving an interactive bisection protocol and a final onchain one-step execution. In particular, a bisection can conclude with a call to the confirmEdgeByOneStepProof
function, which ultimately references the inputs that have been posted onchain.
The bisection protocol is divided into 3 "levels", depending on the size of the step: block level, big step level, and small step level. The interaction between these levels is non-trivial and also effects its economic guarantees.
A dedicated smart contract, the OneStepProofEntry
, manages a set of sub-contracts depending on the type of step that needs to be executed onchain, and finally returns the post-execution state hash to the caller.
The proveOneStep
function in OneStepProofEntry
is defined as follows:
function proveOneStep(
ExecutionContext calldata execCtx,
uint256 machineStep,
bytes32 beforeHash,
bytes calldata proof
) external view override returns (bytes32 afterHash)
The ExecutionContext
struct is defined as follows:
struct ExecutionContext {
uint256 maxInboxMessagesRead;
IBridge bridge;
bytes32 initialWasmModuleRoot;
}
where the maxInboxMessagesRead
is set to the nextInboxPosition
of the previous assertion, which can be seen as the "inbox" target set by the previous assertion to the new assertion. This value should at least be one more than the inbox position covered by the previous assertion, and is set to the current sequencer message count for the next assertion. If an assertion reaches the maximum number of blocks allowed but doesn't reach the nextInboxPosition
, it is considered an "overflow" assertion which has its own specific checks.
The OneStepProofEntry
contract populates the machine value and frame stacks and registries given the proof
. A machine hash is computed using these values and the wasmModuleRoot
, which determines the program to execute. Instructions and necessary merkle proofs are deserialized from the proof. Based on the opcode to be executed onchain, a sub-contract is selected to actually execute the step. The ones that require referencing the inbox inputs are the ones that require calling the OneStepProverHostIo
contract. An Instruction
is simply defined as:
struct Instruction {
uint16 opcode;
uint256 argumentData;
}
If the instruction is READ_INBOX_MESSAGE
, the executeReadInboxMessage
function is called, which either references the sequencer inbox or the delayed inbox, depending whether the argument is INBOX_INDEX_SEQUENCER
or INBOX_INDEX_DELAYED
. The functions compute the appropriate accumulated hash given its inputs, fetched from the sequencerInboxAccs
or delayedInboxAccs
, and checks that it matches the expected one.
The executeReadPreImage
function is instead used to execute a "read" out of either a keccak256 preimage or a blob hash preimage, using the 4844 point evaluation precompile. It is made sure that the correct point is used for the evaluation.
L1 to L2 messaging
Different types of messages can be sent from L1 to L2, and each of them is identified by a "kind" value, as follows:
uint8 constant L2_MSG = 3;
uint8 constant L1MessageType_L2FundedByL1 = 7;
uint8 constant L1MessageType_submitRetryableTx = 9;
uint8 constant L1MessageType_ethDeposit = 12;
uint8 constant L1MessageType_batchPostingReport = 13;
uint8 constant L2MessageType_unsignedEOATx = 0;
uint8 constant L2MessageType_unsignedContractTx = 1;
uint8 constant ROLLUP_PROTOCOL_EVENT_TYPE = 8;
uint8 constant INITIALIZATION_MSG_TYPE = 11;
In ArbOS, other message types can be found, whose purpose is TBR (To Be Researched):
const (
L1MessageType_L2Message = 3
L1MessageType_EndOfBlock = 6
L1MessageType_L2FundedByL1 = 7
L1MessageType_RollupEvent = 8
L1MessageType_SubmitRetryable = 9
L1MessageType_BatchForGasEstimation = 10 // probably won't use this in practice
L1MessageType_Initialize = 11
L1MessageType_EthDeposit = 12
L1MessageType_BatchPostingReport = 13
L1MessageType_Invalid = 0xFF
)
Gas token deposit (ETH)
To deposit ETH on the L2 to be used as a gas token, the depositEth
function on the Inbox
contract (also called "delayed inbox") is used, which is defined as follows:
function depositEth() public payable whenNotPaused onlyAllowed returns (uint256)
Here's an example transaction.
The onlyAllowed
modifier checks an "allow list", if enabled. The control passes to the _deliverMessage
function, which is defined as follows:
function _deliverMessage(
uint8 _kind,
address _sender,
bytes memory _messageData,
uint256 amount
) internal returns (uint256)
The message kind used here is L1MessageType_ethDeposit
. Ultimately, the enqueueDelayedMessage
function is called on the Bridge
contract. The function ultimately pushes an accumulated hash to the delayedInboxAccs
array.
TODO
Derivation
The derivation logic for Arbitrum and Orbit chains is defined in the nitro node.
The L1 node connection is done through the L1Reader
in the getL1Reader
function in arbnode/node.go
. All necessary addresses are fetched from the /cmd/chaininfo/arbitrum_chain_info.json
file. For example, here's the configuration for Arbitrum One:
{
"chain-name": "arb1",
"parent-chain-id": 1,
"parent-chain-is-arbitrum": false,
"sequencer-url": "https://arb1-sequencer.arbitrum.io/rpc",
"secondary-forwarding-target": "https://arb1-sequencer-fallback-1.arbitrum.io/rpc,https://arb1-sequencer-fallback-2.arbitrum.io/rpc,https://arb1-sequencer-fallback-3.arbitrum.io/rpc,https://arb1-sequencer-fallback-4.arbitrum.io/rpc,https://arb1-sequencer-fallback-5.arbitrum.io/rpc",
"feed-url": "wss://arb1-feed.arbitrum.io/feed",
"secondary-feed-url": "wss://arb1-delayed-feed.arbitrum.io/feed,wss://arb1-feed-fallback-1.arbitrum.io/feed,wss://arb1-feed-fallback-2.arbitrum.io/feed,wss://arb1-feed-fallback-3.arbitrum.io/feed,wss://arb1-feed-fallback-4.arbitrum.io/feed,wss://arb1-feed-fallback-5.arbitrum.io/feed",
"has-genesis-state": true,
"block-metadata-url": "https://arb1.arbitrum.io/rpc",
"track-block-metadata-from": 327000000,
"chain-config": {
"chainId": 42161,
"homesteadBlock": 0,
"daoForkBlock": null,
"daoForkSupport": true,
"eip150Block": 0,
"eip150Hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"petersburgBlock": 0,
"istanbulBlock": 0,
"muirGlacierBlock": 0,
"berlinBlock": 0,
"londonBlock": 0,
"clique": {
"period": 0,
"epoch": 0
},
"arbitrum": {
"EnableArbOS": true,
"AllowDebugPrecompiles": false,
"DataAvailabilityCommittee": false,
"InitialArbOSVersion": 6,
"InitialChainOwner": "0xd345e41ae2cb00311956aa7109fc801ae8c81a52",
"GenesisBlockNum": 0
}
},
"rollup": {
"bridge": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
"inbox": "0x4dbd4fc535ac27206064b68ffcf827b0a60bab3f",
"rollup": "0x5ef0d09d1e6204141b4d37530808ed19f60fba35",
"sequencer-inbox": "0x1c479675ad559dc151f6ec7ed3fbf8cee79582b6",
"validator-utils": "0x9e40625f52829cf04bc4839f186d621ee33b0e67",
"validator-wallet-creator": "0x960953f7c69cd2bc2322db9223a815c680ccc7ea",
"stake-token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"deployed-at": 15411056
}
}
Then an InboxReader
is created using the NewInboxReader
function in arbnode/inbox_reader.go
, which, among other things, takes in input the L1 reader, the sequencer inbox address and the delayed inbox address.
Transaction data from the sequencer is fetched using the LookupBatchesInRange
method on the SequencerInbox
type, which returns a list of SequencerInboxBatch
values.
Transaction data from the sequencer inbox is processed using the getSequencerData
method on the SequencerInboxBatch
type in arbnode/sequencer_inbox.go
. The method is called in the Serialize
method.
The SequencerInboxBatch
type is defined as follows:
type SequencerInboxBatch struct {
BlockHash common.Hash
ParentChainBlockNumber uint64
SequenceNumber uint64
BeforeInboxAcc common.Hash
AfterInboxAcc common.Hash
AfterDelayedAcc common.Hash
AfterDelayedCount uint64
TimeBounds bridgegen.IBridgeTimeBounds
RawLog types.Log
DataLocation BatchDataLocation
BridgeAddress common.Address
Serialized []byte // nil if serialization isn't cached yet
}
--- TODO ---
If the segment type is BatchSegmentKindAdvanceTimestamp
or BatchSegmentKindAdvanceL1BlockNumber
, the timestamp or the referenced block number is increased accordingly. If the segment type is BatchSegmentKindL2Message
or BatchSegmentKindL2MessageBrotli
, a MessageWithMetadata
is created:
msg = &arbostypes.MessageWithMetadata{
Message: &arbostypes.L1IncomingMessage{
Header: &arbostypes.L1IncomingMessageHeader{
Kind: arbostypes.L1MessageType_L2Message,
Poster: l1pricing.BatchPosterAddress,
BlockNumber: blockNumber,
Timestamp: timestamp,
RequestId: nil,
L1BaseFee: big.NewInt(0),
},
L2msg: segment,
},
DelayedMessagesRead: r.delayedMessagesRead,
}
Messages are then passed to the TransactionStreamer
by calling the AddMesageAndEndBatch
method.