Skip to content

feat(l1): resolve inbox endpoints by message total and preflight checkpoints - #25459

Open
spalladino wants to merge 1 commit into
spl/fi2-b2-inbox-proven-consumptionfrom
spl/fi2-b3-inbox-endpoint-preflight
Open

feat(l1): resolve inbox endpoints by message total and preflight checkpoints#25459
spalladino wants to merge 1 commit into
spl/fi2-b2-inbox-proven-consumptionfrom
spl/fi2-b3-inbox-endpoint-preflight

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

Context

A node that lets ordinary L2 blocks end at arbitrary message prefixes, and only resolves a live Inbox bucket when a checkpoint is completed and published, needs two L1 primitives it does not have yet: a one-call way to find the newest live bucket boundary at or below a locally known message count, and a publication preflight that checks a checkpoint's header and its final Inbox consumption against the parent propose will actually use at execution time, rather than against a caller-supplied parent total.

Both go in here as additive contract APIs plus their TypeScript wrappers. Nothing on the existing propose path changes, and no node behaviour changes: the publisher still calls validateHeaderWithAttestations.

Approach

IInbox.getBucketAtOrBeforeTotal(uint64 upperBound) -> (uint64 seq, InboxBucket bucket). Cumulative totals strictly increase with the bucket sequence, so the answer is the newest live bucket whose total does not exceed the bound. The live interval is [oldest, current] with oldest = current - ringSize + 1 once the ring has wrapped, computed subtraction-first so current + 1 cannot overflow at the sequence maximum. The search walks back the newest ENDPOINT_WALKBACK_PROBES (4) live entries one by one, checking for oldest before every step down, then binary-searches only the interval the walk did not scan. Overwritten ring entries are never dereferenced, and the genesis bucket is a candidate only while sequence zero is still live. A bound below the oldest retained total reverts with Inbox__NoBucketAtOrBeforeTotal(upperBound, oldestLiveTotal).

IRollup.validateCheckpointHeaderAndInbox(CheckpointPreflightArgs calldata) -> uint64 bucketHint. Inside RollupOperationsExtLib: derive the effective pending checkpoint with STFLib.getEffectivePendingCheckpointNumber(block.timestamp), exactly as propose prunes before validating; require it to equal the parent the caller claims (Rollup__UnexpectedParentCheckpoint), so a simulation whose state overrides do not survive the real prune rule fails here rather than at propose; run the shared header and attestation checks, which compare the header's lastArchiveRoot against that parent's archive; then resolve expectedTotal to a live bucket (Rollup__InboxTotalNotAtBucketBoundary if it lands inside one) and run ProposeLib.validateInboxConsumption on it with the parent's stored total — the same settlement, monotonicity, cap and censorship predicate propose enforces.

CheckpointPreflightArgs bundles the validateHeaderWithAttestations argument set with expectedTotal and expectedParentCheckpointNumber. The flat ten-argument form hits "stack too deep", and a single calldata struct keeps the Rollup's forwarder small: Rollup runtime bytecode lands at 24,461 bytes, 115 under the EIP-170 limit.

Constant alignment. MAX_MSGS_PER_BUCKET now aliases Constants.MAX_L1_TO_L2_MSGS_PER_BLOCK rather than hardcoding 256, so the Inbox rollover and the node-side completion guard that reserves one bucket of checkpoint capacity read one generated value. MAX_L1_TO_L2_MSGS_PER_BLOCK is exported to Solidity through scripts/constants-codegen/solidity.json for it; the value is unchanged.

Bindings. The TypeScript mirrors ride the foundation patch queue as labs-patches/0016-*.patch, since the node lives in the labs submodule: InboxContract.getBucketAtOrBeforeTotal(upperBound) returns { seq, bucket } | undefined (undefined on the not-found revert), RollupContract.validateCheckpointHeaderAndInbox(l1TxUtils, args, { time, stateOverrides, from }) runs the preflight over eth_simulateV1 with a block-time override — the transport the header-only preflight already uses — and returns the bucketHint, and a new getRevertedErrorName helper decodes the custom error a viem call reverted with.

Propose grows about 130 gas from the larger Inbox and Rollup dispatch tables. Gas report and benchmarks regenerated.

Tests

InboxBucketSearch.t.sol: genesis and overwritten genesis, exact and interior bounds, a full bucket and the spill-over bucket that follows it in the same L1 block, a hit on each walkback probe with storage-read-count bounds, the fallback immediately beyond the walk excluding the already-scanned suffix, fewer live entries than probes, ring wrap with an oldest-entry hit and a miss one below it, maximum-sequence arithmetic via harness ring surgery, and a fuzz case against a brute-force reference over getBucket.

CheckpointPreflight.t.sol pairs the preflight against propose with the same parent state and execution timestamp: fresh chain, consuming nothing, interior total, wrong rolling hash, wrong parent identity, settlement at an equal and a later timestamp, censorship, cap escape, a published parent, an automatic prune, an invalidated parent, and the shared header checks. ProposeInboxConsumption.t.sol gains resolve-by-total cases, and InboxBuckets.t.sol pins the bucket cap and the rollover to the generated constant. The two TypeScript wrappers get anvil-backed unit tests.

Stacked on #25457.

…kpoints

Two L1 primitives are missing for a node that lets ordinary L2 blocks end at
arbitrary message prefixes and only resolves a live Inbox bucket when a
checkpoint is completed: a one-call way to find the newest live bucket boundary
at or below a locally known message count, and a publication preflight that
checks a checkpoint's header *and* its final Inbox consumption against the
parent `propose` will actually use at execution time, rather than against a
caller-supplied parent total. Both go in as additive contract APIs; nothing on
the existing propose path changes.

`IInbox.getBucketAtOrBeforeTotal(uint64)` returns the live bucket with the
greatest cumulative total at or below the bound. Totals strictly increase with
the bucket sequence, so that is the newest retained bucket whose end position
fits. The live interval is `[oldest, current]` with
`oldest = current - ringSize + 1` once the ring has wrapped, computed
subtraction-first so `current + 1` cannot overflow at the sequence maximum. The
search walks back the newest `ENDPOINT_WALKBACK_PROBES` live entries, checking
for `oldest` before each step down, then binary-searches only the interval the
walk did not scan. Overwritten ring entries are never dereferenced, and genesis
is a candidate only while its ring entry is still live.

`IRollup.validateCheckpointHeaderAndInbox` derives the effective pending
checkpoint at `block.timestamp` exactly as `propose` does, rejects any parent
other than the one the caller claims, runs the shared header and attestation
checks, then resolves the consumed total to a live bucket and runs it through
the same settlement, monotonicity, cap and censorship predicate `propose`
enforces, returning the sequence to submit as `bucketHint`. The arguments
arrive as one `CheckpointPreflightArgs` calldata struct: the flat form hits
"stack too deep", and a single struct keeps the Rollup's forwarder small enough
to stay under the EIP-170 limit.

`MAX_MSGS_PER_BUCKET` now aliases `Constants.MAX_L1_TO_L2_MSGS_PER_BLOCK`
instead of hardcoding 256, so the Inbox rollover and the node-side completion
guard read one generated value; `MAX_L1_TO_L2_MSGS_PER_BLOCK` is exported to
Solidity for it.

Propose grows about 130 gas from the larger Inbox and Rollup dispatch tables.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant