feat(l1): resolve inbox endpoints by message total and preflight checkpoints - #25459
Open
spalladino wants to merge 1 commit into
Open
feat(l1): resolve inbox endpoints by message total and preflight checkpoints#25459spalladino wants to merge 1 commit into
spalladino wants to merge 1 commit into
Conversation
…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.
spalladino
requested review from
iAmMichaelConnor,
just-mitch and
koenmtb1
as code owners
September 10, 2026 15:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
proposewill 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]witholdest = current - ringSize + 1once the ring has wrapped, computed subtraction-first socurrent + 1cannot overflow at the sequence maximum. The search walks back the newestENDPOINT_WALKBACK_PROBES(4) live entries one by one, checking foroldestbefore 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 withInbox__NoBucketAtOrBeforeTotal(upperBound, oldestLiveTotal).IRollup.validateCheckpointHeaderAndInbox(CheckpointPreflightArgs calldata) -> uint64 bucketHint. InsideRollupOperationsExtLib: derive the effective pending checkpoint withSTFLib.getEffectivePendingCheckpointNumber(block.timestamp), exactly asproposeprunes 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 atpropose; run the shared header and attestation checks, which compare the header'slastArchiveRootagainst that parent's archive; then resolveexpectedTotalto a live bucket (Rollup__InboxTotalNotAtBucketBoundaryif it lands inside one) and runProposeLib.validateInboxConsumptionon it with the parent's stored total — the same settlement, monotonicity, cap and censorship predicateproposeenforces.CheckpointPreflightArgsbundles thevalidateHeaderWithAttestationsargument set withexpectedTotalandexpectedParentCheckpointNumber. The flat ten-argument form hits "stack too deep", and a single calldata struct keeps the Rollup's forwarder small:Rollupruntime bytecode lands at 24,461 bytes, 115 under the EIP-170 limit.Constant alignment.
MAX_MSGS_PER_BUCKETnow aliasesConstants.MAX_L1_TO_L2_MSGS_PER_BLOCKrather 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_BLOCKis exported to Solidity throughscripts/constants-codegen/solidity.jsonfor it; the value is unchanged.Bindings. The TypeScript mirrors ride the foundation patch queue as
labs-patches/0016-*.patch, since the node lives in thelabssubmodule:InboxContract.getBucketAtOrBeforeTotal(upperBound)returns{ seq, bucket } | undefined(undefined on the not-found revert),RollupContract.validateCheckpointHeaderAndInbox(l1TxUtils, args, { time, stateOverrides, from })runs the preflight overeth_simulateV1with a block-time override — the transport the header-only preflight already uses — and returns thebucketHint, and a newgetRevertedErrorNamehelper 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 overgetBucket.CheckpointPreflight.t.solpairs the preflight againstproposewith 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.solgains resolve-by-total cases, andInboxBuckets.t.solpins the bucket cap and the rollover to the generated constant. The two TypeScript wrappers get anvil-backed unit tests.Stacked on #25457.