Skip to content

feat(fast-inbox): resolve inbox endpoints by message total and preflight checkpoints against the effective parent - #25414

Draft
spalladino wants to merge 4 commits into
spl/fi2-count-addressed-replayfrom
spl/fi2-l1-endpoint-resolver-preflight
Draft

feat(fast-inbox): resolve inbox endpoints by message total and preflight checkpoints against the effective parent#25414
spalladino wants to merge 4 commits into
spl/fi2-count-addressed-replayfrom
spl/fi2-l1-endpoint-resolver-preflight

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

Context

The bucketless Fast Inbox rebuild lets ordinary L2 blocks end at arbitrary message prefixes and resolves a live Inbox bucket only when a checkpoint is completed and published. Two L1 primitives are missing for that: 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.

This PR adds both as additive contract APIs plus their TypeScript wrappers. Node behaviour is unchanged: the publisher still calls validateHeaderWithAttestations, and the sequencer/validator selection logic is untouched. P3 switches the node over.

Approach

  • IInbox.getBucketAtOrBeforeTotal(uint64 upperBound) → (uint64 seq, InboxBucket bucket). Cumulative totals strictly increase per bucket, 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 wrapped (subtraction first, so current + 1 cannot overflow at the sequence maximum). The search probes the newest four live entries one by one, checking for oldest before every step down, then binary-searches only the still unscanned interval [oldest, lastScanned - 1]. Overwritten ring entries are never dereferenced and genesis is a candidate only while sequence zero is live. Not found (the bound is below the oldest retained total) reverts with Inbox__NoBucketAtOrBeforeTotal(upperBound, oldestLiveTotal).
  • IRollup.validateCheckpointHeaderAndInbox(CheckpointPreflightArgs calldata) → uint64 bucketHint. CheckpointPreflightArgs bundles the validateHeaderWithAttestations argument set with expectedTotal and expectedParentCheckpointNumber (the flat ten-argument form hit "stack too deep" and the Rollup has only 725 bytes of runtime headroom left; a single calldata struct keeps the forwarder small). Inside RollupOperationsExtLib: derive the effective parent with STFLib.getEffectivePendingCheckpointNumber(block.timestamp), exactly as propose prunes before validating; require it to equal the claimed parent (Rollup__UnexpectedParentCheckpoint(expected, actual)); run the shared header checks, which compare the header's lastArchiveRoot with that parent's archive; resolve expectedTotal to a live bucket (Rollup__InboxTotalNotAtBucketBoundary(expected, actual) if it is interior) and run ProposeLib.validateInboxConsumption on it with the parent's stored total — the same settlement, monotonicity, cap and censorship predicate propose enforces. The old validateHeaderWithAttestations entry point is untouched.
  • Constant alignment. MAX_L1_TO_L2_MSGS_PER_BLOCK is now exported to Solidity through scripts/constants-codegen/solidity.json, and MAX_MSGS_PER_BUCKET aliases the generated constant so the node's completion guard and the Inbox rollover cannot drift apart. A Forge test pins both the alias and the rollover behaviour to the generated value.
  • Bindings. Regenerated L1 artifacts; 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 and the caller's state overrides — the transport SequencerPublisher.validateCheckpointHeader already uses — and returns the bucketHint.

Rollup runtime bytecode is now 24,461 bytes (115 bytes under the limit; it was 725 under before). Gas report and benchmark regenerated.

Tests: InboxBucketSearch.t.sol (genesis/overwritten genesis, exact/interior bounds, full and spilled buckets, a hit on each walkback probe with read-count bounds, fallback immediately beyond the walk excluding the scanned suffix, fewer than four live entries, ring wrap with oldest-entry hit/miss, maximum-sequence arithmetic via harness ring surgery, fuzz against a brute-force reference), CheckpointPreflight.t.sol (preflight vs propose parity: fresh chain, consume nothing, interior total, wrong hash, wrong parent identity, equal/later execution timestamp settlement, censorship, cap escape, published parent, automatic prune, invalidated parent, shared header checks), resolve-by-total cases in ProposeInboxConsumption.t.sol, and anvil-backed unit tests for both TS wrappers.

Stacked on #25413.

Part of A-1928.

…ght checkpoints against the effective parent

Adds `IInbox.getBucketAtOrBeforeTotal(uint64)`: the newest live bucket whose
cumulative total is at or below a bound, found by probing the four newest live
ring entries and then binary-searching only the unscanned live interval. The live
window is derived subtraction-first so the sequence maximum cannot overflow, no
overwritten ring entry is dereferenced, and a bound below the oldest retained
total reverts with `Inbox__NoBucketAtOrBeforeTotal`.

Adds `IRollup.validateCheckpointHeaderAndInbox(CheckpointPreflightArgs)`, which
derives the parent the way `propose` does (effective pending checkpoint at
`block.timestamp`, i.e. after the automatic prune rule), checks the caller's
parent claim against it, runs the shared header checks, resolves the consumed
total to a live bucket and validates it with the same `validateInboxConsumption`
predicate `propose` enforces, returning the bucket sequence to submit as
`bucketHint`. The arguments are bundled in a calldata struct: the flat form hit
"stack too deep" and the Rollup has little bytecode headroom left.

`MAX_L1_TO_L2_MSGS_PER_BLOCK` is now exported to Solidity and `MAX_MSGS_PER_BUCKET`
aliases it, with a Forge regression pinning the alias and the rollover behaviour
to the generated value.
…heckpoint preflight

`InboxContract.getBucketAtOrBeforeTotal(upperBound)` returns the newest live
bucket boundary at or below a message total, or `undefined` when the ring has
evicted every candidate. `RollupContract.validateCheckpointHeaderAndInbox(
l1TxUtils, args, { time, stateOverrides, from })` simulates the parent-bound
preflight over eth_simulateV1 with the intended execution timestamp and state,
the transport the publisher's header preflight already uses, and returns the
`bucketHint` to submit. The publisher keeps calling the header-only entry point
for now.
… search's not-found path

The preflight is exercised against an unpublished parent supplied through state
overrides (tips, archive, slot, fee header and consumed total), and shown to
reject the same child without those overrides and when the parent's recorded
total is ahead of the child. The Inbox wrapper's `undefined` result is produced
against a real wrapped ring by rewriting the Inbox's storage through anvil, and
unrelated reverts and transport failures are shown to propagate.
@spalladino
spalladino force-pushed the spl/fi2-l1-endpoint-resolver-preflight branch from 496cf9b to 942f6af Compare September 5, 2026 06:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-draft Run CI on draft PRs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant