Skip to content

feat(l1): gate inbox bucket eviction on proven consumption - #25457

Open
spalladino wants to merge 1 commit into
spl/fi2-b1-rollup-config-immutablesfrom
spl/fi2-b2-inbox-proven-consumption
Open

feat(l1): gate inbox bucket eviction on proven consumption#25457
spalladino wants to merge 1 commit into
spl/fi2-b1-rollup-config-immutablesfrom
spl/fi2-b2-inbox-proven-consumption

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

Opening an Inbox bucket reuses the ring slot of the bucket INBOX_BUCKET_RING_SIZE positions back. Until now that overwrite was unconditional, which is wrong in two ways. Directly, a burst of traffic destroys messages that are still in flight. Less obviously, it can deadlock consumption: a proposal may only reference a retained bucket, and may only consume MAX_L1_TO_L2_MSGS_PER_CHECKPOINT messages beyond its parent's cumulative total. Heavy traffic pushes the buckets whose delta from a stalled parent total still fits that cap out of the retained window, while every bucket left in the window is already too far ahead of that parent to be consumed in one checkpoint. A chain whose consumption stalled then has no proposable cursor at all, and waiting does not produce one.

This PR gates eviction on the proven chain having consumed the entry being evicted. Inbox.markProvenConsumed(uint64) is rollup-only and monotonic, and reverts if asked to record a bucket ahead of the current one. The Rollup calls it from submitEpochRootProof on every proven-tip advance that moved the rolling hash, passing the bucket sequence the newly proven checkpoint recorded at propose time. The bucket-open path then refuses to evict a slot the record does not cover, reverting with Inbox__WouldOverwriteUnconsumedBucket.

Anchoring on proven consumption rather than pending consumption is deliberate. The proven tip never rewinds, so the record is prune-immune: a pruned chain's consumption never unlocks eviction, and re-proposing a checkpoint number overwrites its temp log wholesale, so the replacement's consumption is what eventually gets recorded. The record can only lag the truth, never lead it, so the failure mode is refusing a legal eviction rather than allowing an illegal one.

Exhausting the ring now halts sends instead of overwriting. getRingHeadroom() exposes how many bucket openings are left, so a caller can see the wall coming. Note it counts openings, not messages: at zero headroom a bucket still open in the current L1 block and below the per-bucket cap keeps absorbing.

The ring grows from 1024 to 4096 entries. It has to cover the worst-case proving lag with enough margin that adversarial bucket creation cannot cheaply exhaust it — a forced rollover costs roughly 2.2M gas, so burning 4096 buckets of headroom takes about 250 continuously-owned L1 blocks. Ring size bounds retention only: two storage slots per live bucket, no per-send gas.

Carrying the consumed bucket needs one more uint64 on the checkpoint temp log. It goes next to the slot number and the cumulative message total, which already share one storage word, so recording it costs no extra slot. STFLib.getInboxConsumedBucket reads it back on the proven-tip advance.

New tests cover the Inbox side (InboxOverwriteProtection.t.sol: the exact boundary, resumability, monotonicity, authorization, headroom semantics, batch atomicity, and a fuzz run against a model of the ring) and the deadlock argument from the consumption side (InboxRingDeadlock.t.sol, which reproduces the gap against a contract whose record is pushed ahead of real consumption and shows it cannot open with protection in place). Rollup.t.sol covers the proven-tip advance, the empty-epoch skip, and both prune cases.

Propose grows about 310 gas and an epoch proof about 40.

The TypeScript mirror rides the foundation patch queue as labs-patches/0015-*.patch: the checkpoint-log override builders have to write the new field, since supplying either of the other two fields in that word would otherwise zero it.

Stacked on #25456.

Opening an Inbox bucket reuses the ring slot of the bucket
`INBOX_BUCKET_RING_SIZE` positions back. Until now that overwrite was
unconditional, so a burst of traffic could destroy messages that were still in
flight, and worse, could slide the retained window off the only buckets a chain
whose consumption had stalled could still propose against -- a gap that never
closes, because every bucket left in the window is too far ahead of the stalled
parent total to be consumed within one checkpoint's cap.

Eviction is now gated on the proven chain having consumed the evicted entry.
The Rollup pushes `markProvenConsumed` on every proven-tip advance that moved
the rolling hash, reading the bucket sequence the checkpoint recorded at propose
time; the Inbox refuses to open a bucket whose ring slot is not covered by that
record, so exhausting the ring halts sends rather than overwriting. Anchoring on
proven consumption is prune-immune -- the proven tip never rewinds -- and
fail-closed, since the record can only lag the truth.

`getRingHeadroom()` exposes how many bucket openings are left, and the ring
grows from 1024 to 4096 so it covers the worst-case proving lag with enough
margin that adversarial bucket creation cannot cheaply exhaust it.

Carrying the consumed bucket needs one more `uint64` on the checkpoint temp log,
which shares the slot-number storage word with the message total, so recording
it costs no extra slot. Propose grows about 310 gas and an epoch proof about 40.
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