feat(ethereum): cap the L1 block range of every eth_getLogs request - #262
spalladino wants to merge 79 commits into
Conversation
The fee predictor pins every non-constant rollup read to a single L1 block so its cached snapshot is internally consistent, but the protocol fee margin was always read at latest. Governance can change the margin at any time, so a refresh racing an update could mix values from two different blocks.
mapRange dropped a 0n start or end because zero is falsy, turning an exclusive end of zero into an unbounded range. Zero is a valid compact key for the archiver's Inbox message log, so an empty range there read the whole log.
…e source Adds InboxMessagePosition and InboxMessageRange next to the L1ToL2MessageSource interface, with getMessagePosition, getSyncedMessagePosition and getL1ToL2MessageRange on the message store, the archiver data source, its RPC schema and the shared mocks. The range read returns the messages and both bounding positions from one store transaction, empty ranges included, so the ending hash authenticates exactly the returned messages and cannot describe a different version of the log. Positions are read from the existing per-index message records; no schema change. Leaf-count ranges now address canonical compact message indices instead of resolving both bounds to a synced bucket boundary, and report an unavailable range as InboxMessageRangeNotSyncedError, replacing InboxBucketBoundaryNotSyncedError. A published block commits to a leaf count while an L1 reorg can merge away the bucket that ended there, so a bound interior to the current partition must still resolve. Bucket lookups are unchanged. The mock message source derives positions and ranges from its indexed leaf log, checks the mocked synced tip and reads leaves and hashes without yielding, so it cannot pair one version of the log with another's hash or resolve ranges the archiver would reject.
World state replayed each published block's consumed message bundle by resolving the parent's and the block's committed leaf counts to Inbox buckets, and the validator derived a checkpoint's bundle the same way. An L1 reorg that merges buckets leaves those committed counts interior to the current partition, so the lookup misses and the node silently applies the block, or attests the checkpoint, with an empty bundle. Both now read the range by count: the bounds address canonical compact message indices, which stay resolvable however the buckets are repartitioned. World state also fetches and applies each block before reading the next one's range, so a range the archiver cannot serve yet fails the sync without discarding the blocks already applied, and a missing parent block throws instead of defaulting to a zero leaf count that would request every message ever received. On the validator side, whether the checkpoint's final position is a live bucket end stays the publication rule L1 enforces, still checked separately by the minimum-consumption guard. A parent block that is unavailable locally is now reported as a fetch error rather than an empty bundle, which would have failed the rolling-hash recomputation and been classified as a slashable header mismatch.
Replace `InboxBucketRef` with `InboxMessagePrefixRef`: one rolling hash, interpreted together with the block header's L1-to-L2 leaf count (`state.l1ToL2MessageTree.nextAvailableLeafIndex`), naming the message prefix the block consumed through. Intermediate blocks may end at any prefix; no bucket boundary is involved, and only a completed checkpoint's final position has to resolve to a live L1 bucket. Block and checkpoint proposals carry the reference in the same optional, append-only-when-set tail the bucket reference used, so the wire fixtures and the EOF-reads-as-unset decode are unchanged. `L2BlockSink.addBlock` now requires the reference so the sink can validate consumption against its own messages.
…nsert transaction `addProposedBlock` now takes the block's signed Inbox prefix reference and checks it against the store's own canonical messages in the same transaction as the writes: the parent block must be present, consumption must not rewind, the consumed count range must be servable whole, and the rolling hash at the block's L1-to-L2 leaf count must equal the reference. Because message replacement and block insertion each run as one transaction on the same store, the two orderings converge: a replacement that commits first makes this validation fail, and an insertion that commits first is visible to the replacement's prune. Without it, a block built before an L1 reorg replaced the messages it consumed could land after the reorg had already pruned the chain it belongs to. Each rejection gets its own error name so a caller that only sees the `L2BlockSink` interface can classify it without importing the archiver.
Validators now check a block proposal's consumed position as a pair of signed values: the end count from the block header's L1-to-L2 leaf count and the prefix rolling hash from the proposal's reference. Because each message's rolling hash chains the one before it, a matching hash at the end count proves the proposer consumed exactly the prefix this node holds, so the bundle between the parent's count and this one is determined by content alone. No bucket is resolved and no message recency is checked; the end position need not be a bucket boundary. The bundle and the prefix hash it ends at are read from one snapshot before re-execution, so a message replacement landing between the metadata check and the read surfaces as an unconfirmed prefix rather than as a slashable `state_mismatch` against leaves the proposer never saw. A re-execution mismatch is likewise demoted to a local disagreement when the local prefix at the block's end count has moved since. `inbox_prefix_unavailable` and `inbox_prefix_mismatch` are both local-view outcomes: they are `unvalidated`, retried through the deadline-bounded local sync, and never slashed, because a node that has not yet followed a reorg holds a present-but-stale prefix hash that is indistinguishable here from a proposer naming a prefix that never existed. Checkpoint validation reads the consumed range by count and compares its ending hash with the header's `inboxRollingHash` instead of resolving buckets or checking censorship, which is left to the proposer's publication preflight and to L1 `propose`. The checkpoint's slot is read once, keyed by the signed archive, so a local prune between reads no longer produces the slashable `last_block_archive_mismatch` verdict; `no_blocks_for_slot` becomes unreachable and is removed.
The proposer and automine now build an `InboxMessagePrefixRef` from the rolling hash of the prefix their selection consumed through, sign it into the block proposal, and hand it to the archiver so the insert transaction re-validates it. A block that consumed nothing reuses the parent's reference. The bucket hint the publisher still needs comes from the streaming cursor directly. Selection itself is unchanged: it keeps resolving buckets, and the prefix reference is derived from the bucket's rolling hash, which is the prefix hash at that bucket's cumulative total.
Replaces the Inbox bucket selector with a bucketless selection module. An ordinary block takes every message the local archiver has observed, bounded only by the per-block and per-checkpoint caps, with no L1 call and no bucket boundary. A block whose prospective end would pass the checkpoint cap less one bucket, and any block that ends a checkpoint, resolves a live L1 bucket end with a single Inbox read and authenticates the range to it against the local log in one snapshot. The threshold keeps one bucket of checkpoint capacity in reserve, so a cursor at or below it can always reach the end of the bucket it sits in. The old bucket-age and L1-confirmation eligibility rules are gone and are not replaced.
…ed per block The checkpoint proposal job drives the bucketless selection. Every block consumes greedily on the local log; the block that crosses the completion threshold, the block that reaches the per-checkpoint block cap, and the timetable's last block each resolve their own live L1 bucket end, rather than fixing one completion target early and carrying it across the rest of the checkpoint. A checkpoint that cannot end on a live endpoint is abandoned rather than published. The explicit consumption cursor advances only when a block has actually built, so a failed attempt re-derives the same range in the next sub-slot instead of losing it, and the forced tx-less tail block advances state only after its build succeeds. Both integrated preflights are bounded by the deadline of the phase they serve, and the publication preflight keeps the build's proven pin while a prune is due, since it simulates against the proof it assumed. The job now takes an Inbox contract rather than an L1 block reader; the sequencer and the client construct it.
…p and publication Replaces the publisher's header-only `validateCheckpointHeader` simulation with the Rollup's integrated `validateCheckpointHeaderAndInbox`. The call derives the parent the way `propose` does, runs the shared header checks, resolves the checkpoint's final message total to a live Inbox bucket and applies L1's settlement, cap and censorship rules to it. It returns the bucket sequence, which is the unsigned hint the send carries.
Automine builds a single-block checkpoint, so its one block has to land on a live bucket end: it bounds the consumed total by what the archiver holds and the caps, resolves the endpoint with one Inbox call and authenticates the range against the local log. The node's public-call simulator runs the same local-only part of the proposer's selection — every observed message up to the per-block cap and the completion threshold. Above that threshold the proposer's end depends on a live L1 bucket end the node does not read, so the prediction stops where the local log alone is authoritative.
Two new single-node cross-chain suites. The bucket suite drives a checkpoint onto a live bucket end and covers the completion endpoint hash mismatch; the backlog suite fills the Inbox while production is paused and then asserts what a late-starting proposer can and cannot publish, and that the backlog is recovered on published checkpoints. The shared helpers grow the Inbox reads both need, and the streaming Inbox suite's comments describe prefix consumption rather than the removed bucket-age rule.
Blocks consume message prefixes rather than whole buckets, so the censorship cutoff timestamp and the bucket-boundary sufficiency predicate no longer have a caller. The catch-up floor becomes ceil(MAX_L1_TO_L2_MSGS_PER_CHECKPOINT / MAX_L1_TO_L2_MSGS_PER_BLOCK): each block carries a full block cap whatever L1's bucket partition looks like, so a cap-sized mandatory backlog clears in that many blocks.
…om L1 reorgs by comparing content The archiver no longer partitions its Inbox storage by L1 bucket. `MessageStore` keeps one ordered log, a row per message at its compact index holding the leaf, the cumulative rolling hash and the L1 block the message was observed in; positions and count ranges derive from the rows alone. Blocks consume message prefixes, and only a completed checkpoint's final position has to land on a live bucket end, which the proposer resolves against L1 at publication time, so nothing downstream needs L1's partition. The bucket abstraction goes with it: `InboxBucket`, the four bucket getters on `L1ToL2MessageSource`/`ArchiverApi` and their schema entries and mocks are deleted, and `InboxMessage` drops `bucketSeq`, `bucketTimestamp` and `l1BlockHash`. Message sync moves into `InboxMessageSynchronizer`. The invariant it maintains is that a persisted syncpoint certifies the entire stored prefix and the completeness of a log response is never inferred from block identity, so two positions are persisted: the scanned cursor says only which L1 blocks were queried, while the syncpoint is the L1 block at which the stored log's position was found equal to the Inbox's own. Forward ingestion commits each bounded batch with the cursor covering it, but the batch reaching the captured head is staged and committed with the head as syncpoint only once the resulting position equals the Inbox's at that head, which certifies the intermediate batches with it. Storing messages without such a comparison clears the syncpoint. Only the syncpoint may answer a head as synced, advance the finality marker or be inherited as canonical. Recovery replaces the old message-by-message comparison with a roll back and refetch. It first finds an anchor: either the canonical tip is a shorter prefix of the local log, checked by hash with no event lookups, or a stored message L1 still emits at the same index and hash within five L1 blocks of its recorded height, found by walking the log backwards with a bounded lookup budget per pass. Running out of candidates falls back to the deployment block. The anchor is then committed as one store transaction: the suffix rows are deleted, the proposed blocks that consumed past the retained count are pruned with their descendants, the cursor rewinds to the block before the anchor's and the syncpoint is cleared. Nothing is fetched in that pass, so the prune cannot be lost behind a later failed request; ordinary forward ingestion refills the log. Lookups are bounded above by the captured head, an inverted lookup range is a miss rather than a query, an RPC exception is not a miss and commits nothing, and the recovery stays pinned to the head it started against. When a replacement reaches below the checkpointed tip's consumed count the published chain is L1's to reconcile, so the archiver gates speculative work instead of pruning it: proposed checkpoints are withheld, no L2 slot is reported as synced and the L1 head is not advertised. The gate is re-derived from persisted state every iteration, by comparing the latest checkpoint's `inboxRollingHash` against the message log, so a restart between a replacement and its reconciliation rebuilds it. A head that does not pass the checkpoint syncpoint now reconciles the checkpointed chain against L1 rather than doing nothing, without issuing any forward log range. Retrieval also learns to bisect an oversized `eth_getLogs` range at block boundaries; a single block the provider cannot serve is thrown rather than reported as an absence of messages. Reconstructs the final state of aztec-packages #25415 commits a1d532886c, 6b9f61fbba, 93f4ddc07a, 8725435acc, 80ee2c085b, c55620e113, ae3c990f55, 1cd02d03c7, a716127470, e953f0987d and cfcc556076.
The archiver README now walks the bucketless sync: the two persisted positions, batch-by-batch forward ingestion with a staged head batch, the bounded anchor search and the single rollback transaction, and the speculation gate. The sequencer and validator READMEs describe greedy prefix consumption, the integrated publication preflight and the signed prefix reference instead of bucket eligibility, and the streaming Inbox e2e comments say a message is consumable once the proposer's archiver has observed it. Reconstructs the documentation half of aztec-packages #25415 commit 5e9a0ede6f.
Every L1 block the Inbox message synchronizer depends on was confirmed with a boolean, so a read that threw, a provider that had not reached the height and a provider that had pruned the range all came back as "this block was replaced". On that reading the synchronizer restarted a recovery that had already spent its lookup budget, or rolled the message log back and pruned the proposed blocks that had consumed the deleted messages, on nothing more than a failed request. An L1 block now reads back as canonical, positively replaced, or unreadable. Only a positive replacement is evidence of a reorg: an unreadable head keeps an in-flight recovery's search position, an unreadable scanned cursor makes the pass wait rather than inherit an unverified prefix or delete on no evidence, and an unreadable head during a rollback keeps the recovery instead of restarting it. The shorter-head path asks the same three-outcome question of the certified syncpoint above the head, and reports lag, positive replacement and unresolved separately. A syncpoint above the head that is still canonical means the chain did not shorten past it and this provider is simply behind, so the log and the speculative blocks that consumed it survive. Only a positively replaced syncpoint lets the log be shortened to the head; an unreadable one keeps it. A head that disagrees with the local log at its own count is a different question and still goes to recovery, which finds its anchor on L1, so a chain that really did shorten is not left waiting for a block that will never come back. A missing recovery candidate no longer reports the Inbox's tip hash as the hash it expected at that count.
The scanned cursor is exclusive and defaults to the deployment block, so ordinary ingestion resumed one block later and never read the block the contracts were deployed in. The Inbox's first message can be emitted by a later transaction in that block, and index 0 was therefore skipped permanently: no later message can fill the gap, so every pass rediscovered the same disagreement. The same applied to a store a zero-anchor recovery had already rewound onto the deployment block. Ingestion now starts at the deployment block while the cursor still sits on it and keeps exclusive semantics once the cursor has moved past it. Re-reading that one block is harmless, since the store rewrites an unchanged message in place. The zero anchor also reports the deployment block itself rather than the block after it.
RecoveryState carried a finalized L1 block that nothing ever read, so it is removed from the type, from startRecovery's signature and from its six call sites. The finality marker itself is untouched: syncPass still takes finalizedL1Block and still hands it to truncate and to every setMessageSyncState call. The synced-status downgrade after continuing a recovery is unreachable since recovery became rollback-then-refetch: continueRecovery returns pending when the anchor search runs out of budget and pending from rollbackTo either way, so it can never return synced. Removed along with the pinned-head alias it needed. Two consumed-message-count call sites now reuse blockLeafCount instead of restating the leaf-count expression.
A stored message row keeps the L1 height it was first observed at, and recovery accepted any candidate recorded at or below the persisted finality marker as an anchor with no event lookup. That recorded height is never refreshed while the log agrees with the Inbox at the captured head, so a message re-mined to a higher block keeps its old, lower height — which can sit below the marker while the message itself is above it, unfinalized and replaceable. When such a message is later replaced, recovery keeps a prefix L1 no longer has: it rolls back from N to N, refetches, fails to chain the replacement onto the retained prefix, and restarts on the same false anchor. The node then makes no further progress on messages until it is restarted. Every anchor is now a message a bounded event lookup positively found on L1 at the same index and rolling hash. The per-pass lookup budget, the lookup window bounded above by the captured head, the deployment-block fallback and the single-transaction rollback are unchanged, and no new cache or persisted state is introduced. The finality marker is still written and advanced monotonically on authenticated syncs: what is removed is reliance on stale placement, not finality itself. The cost is that a recovery which used to stop early keeps walking backwards, and may prune proposed blocks whose messages sit below the marker; those messages return through ordinary forward ingestion.
…ad backwards addBlock triggers a sync it does not await, so a fixture that adds local blocks and then moves the L1 head backwards leaves a pass in flight that captured the pre-reorg head. Dropping the finalized-height shortcut made that pass do event lookups and a rollback instead of returning at once, so it can now commit after the pass for the new, lower head and leave the old height as the synced one. Two fixtures hit this. The deployment-refill one failed about once in twelve runs before this change and about three in ten after it; the shorter-chain truncation one only became unstable here, at two in twelve. Draining the pending sync at both sites fixes them, fifteen runs clean.
A stored message's L1 height records where it was first observed, not where the canonical chain carries it after a reorg. The anchor search only looked eleven L1 blocks around that height, so a message re-mined any further away was never placed and the search walked back past prefixes L1 still holds, discarding unchanged messages and pruning the proposed blocks that consumed them. The lookup now covers an inclusive hundred-block window around the recorded height, clipped at block 1 and at the captured head. Clipping shortens the window rather than sliding it, so a window near genesis or near the head never reaches blocks outside the range it was asked for and an anchor can still never sit at or past the head. Some endpoints refuse a hundred-block eth_getLogs. The bisecting fetch the forward scan already used is hoisted to the ethereum package and reused for the by-hash query, so a refused range is halved and retried rather than narrowed: a rejection still ends as an error, never as an empty result.
… helper addBlock resolves once the block is stored but triggers a sync it does not await, so every fixture that adds local blocks and then moves the L1 head backwards races the pass left in flight: recovery against the stale head can commit after the pass for the new head and leave the old height as the synced one. Two fixtures hit this, and both carried their own inline drain. Drain once in the shared helper instead, so the hazard is gone for the whole block and the two call-site drains become the no-op second calls they now are.
The fixture moved the messages to a later L1 block without replacing any block identity, so nothing on L1 ever disagreed with the log: it passed as an ordinary forward append and never reached recovery, while its name claimed it exercised a re-mine beyond the lookup window. Deleting the move left it green and unchanged, which is the proof. It is now split in two. The plain forward append it was really testing keeps its assertions under a name that says so, and a new fixture re-mines the descendants as well: L1 replaces every block from the messages' height on, the messages come back with their content, index and rolling hash intact sixty blocks later, and two more follow them. Every bounded lookup misses, the anchor falls back to the deployment block and the block that consumed the three messages is pruned even though they return unchanged. Discarding work that moved outside the bounded lookup window is the accepted cost of rolling back before refetching, and the test says so rather than only pinning the end state: it counts the three lookups and the prune that recovery performs.
…ory Inbox backlog validateNetworkConsensusConfig applies MIN_BLOCKS_FOR_INBOX_CATCHUP to a generated profile's configured maxBlocksPerCheckpoint, but a running proposer is bounded by the smaller of that cap and what its own slot timings derive. Timings that shrink the derived count below the floor leave a proposer whose publications L1 always rejects, losing every one of its slots. Check the effective count at startup and before committing a config update; a rejected update leaves the previous config and timetable in place. Sandbox and e2e profiles deliberately run one or two blocks per slot against an Inbox nobody floods, so they warn instead of failing. The exemption is boundary-inclusive: the single-node e2e default runs at exactly FAST_PROFILE_ETHEREUM_SLOT_DURATION so that it keeps the production timing budgets, and at that cadence a 16s L2 slot derives 2 block opportunities. isFastLocalProfile names the strictly-below budget-clamping condition that resolveTimingBudgets already used.
…nbox floor MULTI_VALIDATOR_BLOCK_PRODUCTION_TIMING leaves attestationPropagationTime per-test, and proof_boundary was one of the suites on the profile not setting one. At the default of 2 its 24s slot derives 3 block opportunities against a floor of 4, so the new capacity check refuses the config and every case fails in setupTest. Its two siblings already pin 1 and 0.5; 1 derives 4 here.
simple passes no setupOpts, so MULTI_VALIDATOR_BLOCK_PRODUCTION_TIMING leaves attestationPropagationTime at its default of 2 and its 24s slot derives 3 block opportunities against a floor of 4, which the capacity check refuses at node creation. Set it at this call site rather than in setupSimpleBlockProduction: high_tps shares that helper and deliberately overrides the cadence to 36s/6s.
…schedules them The developer proving-time simulation gated inbox parity behind the first block root and refused a zero-transaction block anywhere but first. Production does neither: a zero-tx block enqueues its own block root from startNewBlock at any position, and parity runs independently of block-root production, joining at checkpoint-root readiness. The join is now reevaluated from whichever dependency finishes last and guarded against a duplicate enqueue, with coverage for no/one/many transactions, mixed empty blocks, and parity landing first or last. This corrects a developer model. It is not a measured production regression, and its timings are model output.
findInsertingBlock scanned forward from a block number the caller sampled and returned the first block that resolved a membership witness. The L1-to-L2 message tree is append-only, so every block after the insertion resolves one just as the inserting block does: whenever the message was already inserted by the sampled bound, the helper reported where the search started rather than where the message went in, and nothing in it would notice. It now locates the block by the message's own compact leaf index against the committed leaf count, which grows monotonically along the chain, bisecting the whole chain instead of trusting any bound. The answer is confirmed at both states -- the block resolves a witness at the message's own index and its actual parent resolves none -- and a chain that moved under the search is a genuine timing miss, retried a bounded number of times.
…cement-only reorg The scenario sent the two messages in separate L1 blocks and replayed them in one, which deletes the bucket boundary between them. A checkpoint that had already ended on that boundary and was still waiting to be published then has no live bucket end to propose against at all, so the proposer is right to prune and rebuild: merging is not placement-only for such a checkpoint, and the test was asserting the opposite. The messages now start in one L1 block and the replacement gives each its own, so the reorg only adds a boundary and every bucket end that existed before it still exists after it. `sendL1ToL2MessagesInOneBlock` submits the pair with consecutive explicit nonces and without waiting for a receipt in between, so the miner cannot split them.
… window The `propose` bucket hint is an unsigned Inbox bucket sequence, but it was resolved as soon as attestations were collected, while the transaction itself is only simulated and sent once `sendRequestsAt` reaches the target slot's send window — up to a full slot later. An L1 reorg that re-partitions the Inbox in between renumbers buckets without changing a single message, so the hint ends up naming a bucket that no longer ends where the checkpoint does and the propose reverts with `Rollup__InvalidInboxRollingHash`. The slot is then abandoned, the archiver prunes every block after the last checkpointed one, and the checkpoint is rebuilt from scratch at a later slot. The publication preflight now waits for the send window before it reads anything from L1, so the hint it returns describes the chain the propose is actually simulated against.
…e is bypassed `skipWaitForValidParentCheckpointOnL1` exists so a node can publish a checkpoint that descends from a parent its own archiver rejects. Since the pre-publication preflight moved inside the propose send window, it now runs at the same L1 block on which the archiver prunes every block of a slot that closed without a valid checkpoint. Rejecting the parent is precisely what prunes this checkpoint's blocks, so the guard abandoned the slot every time and the descendant was never published.
Wire BOT_MODE=inbox into the Helm chart and the deploy-aztec-infra Terraform stack beside the transfers, swaps and cross-chain bots. Every BOT_INBOX_REPLICAS default is 0, so this is a no-op on every environment: the Terraform release is simply absent until a network opts in. The bot chart gains BOT_L1_TO_L2_SEED_COUNT, BOT_L1_TO_L2_TIMEOUT_SECONDS, BOT_INBOX_MESSAGES_PER_BATCH, BOT_INBOX_CONSUME_MODE and BOT_INBOX_SATURATION_INTERVAL_SECONDS. The three inbox-only keys are gated on botMode so the other flavours render a byte-identical configmap and their pods do not roll; the two seed/timeout keys are gated on being set, which keeps the cross-chain bot on the code default it runs today. The daily saturation batch is off. It sends 257 sendL2Message calls in a single atomic Multicall3 aggregate3 transaction to force a bucket rollover, so it wants its own measured rollout rather than riding along with steady-state traffic. The bot code defaults the interval to 86400, so both values.yaml and bot-inbox.yaml set it to 0 explicitly rather than relying on a default. The release takes dedicated BOT_INBOX_DA_GAS_LIMIT and BOT_INBOX_L2_GAS_LIMIT rather than the shared BOT_DA_GAS_LIMIT and BOT_L2_GAS_LIMIT the other three bots inherit. Staging pins those shared limits to values tuned for a token transfer; a private consumption of an L1 to L2 message is a different circuit, and a fixed limit that is too low fails the transaction while one that is too high overpays. Both inbox variables default to empty so the wallet estimates. calculate_publisher_indices.sh reports mnemonic index 7300 once the bot is enabled. That list is what ensure_funded_environment tops up on Sepolia, where CREATE_ETH_DEVNET is false, so without it the bot's L1 account never receives ETH and fails on its first aggregate3.
The proving-lane split added aztec.bot.inbox.l2_send_duration and aztec.bot.inbox.l2_active_attempts, but both instrument-contract tests still enumerated sixteen instruments.
…d them The archiver indexed each scheduled contract class change under [address, schedulingTimestamp, indexWithinBlock]. Two blocks that share a timestamp therefore collide: the later block's update lands on the same key as the earlier one, so a lookup can resolve the wrong class id, and unwinding the later block deletes the entry the earlier block had written. Key stored updates by [address, schedulingTimestamp, blockNumber, indexWithinBlock] instead, and thread the block number through ingestion and rollback so insertion and deletion agree on the key. Timestamps are encoded as 20-digit decimal strings so that lexicographic key ordering matches numeric ordering; previously an update at a timestamp with fewer digits than the queried one fell outside the lookup range entirely. Range boundaries keep their own two-component type, so a partial key can no longer be built where a stored one is expected. ARCHIVER_DB_VERSION moves to 11: the two layouts cannot coexist in one map and the colliding entries cannot be reconstructed from the surviving index, so nodes resync the archiver on upgrade. Noted in the migration notes.
Supersedes the "Noted in the migration notes" line in the previous commit: the ARCHIVER_DB_VERSION bump carries no migration-notes entry.
Range boundaries were typed as the map's own key type, so a store keyed by a tuple could not express "every key under this address" without inventing values for the components it did not want to constrain. Callers worked around it by widening the map's key type to a union of the stored key and the boundary shape, which also made the key type stop proving that only whole keys are ever stored. Range<K> now takes KeyPrefix<K>: for a tuple key that is the key itself or any of its prefixes, and for every other key type it is the key type unchanged. Array keys already order element-wise in all four backends, so a prefix is a well-defined boundary; this only lets the type say so. Backend helpers that serialize a boundary take Key rather than K, since TypeScript cannot prove K extends KeyPrefix<K> while K is still generic, and they only ever hand the value to the key encoder. mapRange returns CustomRange<K> for the same reason; its result is still assignable at concrete call sites. The archiver's contract instance update map drops its union and is keyed by the stored key alone, and its two key builders become private. Test coverage is added to the shared map suite, so it runs against lmdb, lmdb-v2, indexeddb and sqlite-opfs. It asserts on values rather than keys: lmdb-v2 does not round-trip a tuple key through keysAsync, which is a separate pre-existing issue.
The gate's budget question was only ever 'is the proposal send deadline open', which stays true for a whole block sub-slot after the last one a proposer can start, and its snapshot of remaining sub-slots went stale the moment a hold began spending them. The phase event now carries the proposer's own scheduling view, so a held test asks ProposerTimetable what is still startable instead of repeating its arithmetic. Also closes the lifecycle holes around the watchdog: a timeout now fails the match and completion channels rather than leaving consumers waiting on an outcome that can no longer arrive, withHold races the failure channel against the whole held body instead of only the match, and a body that outlived its hold refuses further chain work rather than mutating state behind a resumed proposer.
…tion canStartAnotherBlock asked the timetable at the current instant, which still returns the sub-slot the block was just built in whenever the build finished early, and compared it against indexWithinCheckpoint, which drifts from the sub-slot index as soon as a sub-slot produces no block. Both made an early-built block look like the last one possible. The event now reports the sub-slot it was built in, and the schedule answers for the iteration the loop will actually run: no earlier than the held sub-slot's deadline, and bounded by the checkpoint's block count cap as well as by time. Releasing before any phase matched now settles the match as a cancellation instead of leaving a withHold body waiting for a phase the gate has stopped listening for.
…reconciliation E1 proved its public consume while holding a live proposer, spending the slot's budget on a client-side proof rather than on the L1 round trips the scenario needs. Nothing else sends to this Inbox, so the compact index is known before the hold and the proof is done up front; the send then asserts it got that index, so a competing sender fails the premise rather than the assertion. The same-block and proof claims now pin the block by hash, not by number, which a prune could reuse. E3 armed its gate after both L1 sends, so a running proposer could consume them before there was anything to hold it, and waited for reconciliation on a message count and rolling hash that a placement-only reorg leaves unchanged — a wait that was already satisfied when it started. Production is now stopped across the sends and the gate armed before it resumes, and the wait is on the archiver's message syncpoint naming the replacement L1 chain. Its never-pruned claim is backed by prune events collected across the whole window instead of a read of the final chain, and the prover's next submission is deferred past the replacement rather than being left to race it.
The syncpoint wait accepted any height above the replacement anchor without checking its hash, and the pre-reorg syncpoint normally sits above that anchor — so the wait was satisfied before any reconciliation had happened. It now requires the canonical chain to actually hold the block the syncpoint names, at its own height, on every path; a syncpoint left on the abandoned suffix fails that whether it is at the same height or higher. Extracted with its own unit regressions. The gate was also armed after the sequencer restarted rather than before, and the prover deferral was installed after the window had already been scanned, so neither prevented what it claimed to. The window scan missed checkpoint publications and proofs entirely: both go through the Multicall3 forwarder, not straight to the rollup. E1's background readiness poll now runs against a view the case can cancel, so a failure inside the hold settles it instead of leaving it polling a node the fixture is tearing down.
…ted block decision A multi-node reorg test cannot tell a prefix mismatch that recovered from one that persisted: the metadata helper retries a local-view mismatch until its deadline, so both look the same by the time a verdict exists. Two optional in-process observations make the difference visible — the first comparison, and the decision once classification and any slashing side effect have run — with slashability read from the production table rather than a second one kept for tests. Injected through the node factory like the existing checkpoint hooks, so they are absent from every path that does not pass them and unreachable over RPC. Each test node gets its own object, which is what lets a multi-node test hold one proposer and assert on a named validator. Also surfaces the streaming-Inbox checkpoint abort as an event beside the metrics and log line it already produced, and lets the multi-node proof helper name expected sequencer failures instead of clearing the list.
…to-L2 case The happy path already has single-node coverage. What only multi-node can show is that a validator reading a changed message prefix treats it as its own view moving rather than as proposer misconduct, that the proposer gives up the abandoned slot for the right reason, and that the committee recovers quorum on a replacement built from the canonical prefix. The stale block is signed and stored before L1 changes and gossiped after, so the validator's mismatch is against a parent it still holds. Every claim is read from an observation attached before the release: the first metadata comparison, the completed decision, the prune events, the slashing emitter and the proposer's abort — not from logs, and not from the disabled slasher API.
…al controls The first draft asserted on the message payload rather than the leaf hash the Inbox emitted, picked its replacement checkpoint with a helper that can return an older happy-path one, allowed every failure at the abandoned slot, and relied on a post-hoc scan of the reorg window instead of stopping anything from entering it. Now: the replacement's hash comes from the MessageSent event its replacement block emitted; the checkpoint is the first past the parent whose blocks actually insert it, with the abandoned slot excluded across the whole published range and the pinned block identity rechecked after proving; the named validator's signature is recovered from its attestation over the published archive rather than read off address metadata; only the abandoned slot's own inbox_prefix_reorged abort is allowed through the sequencer failure list; and the window is protected by pausing every sequencer between checkpoints, deferring the prover's next submission before anything is sent, and using a dedicated L1 sender, with the scan left as confirmation. The held work also checks the job's live schedule and ingress budget immediately before releasing, guards each mutation against a watchdog that already fired, and awaits the completion channel instead of swallowing it. The validator's decision observation reports the node's real answer: an open escape hatch rejects a proposal that validated, so it is reported alongside the verdict rather than after it, and the observers are now exercised through ValidatorClient.validateBlockProposal and its actual offense emitter.
…org replacement keep its gas limit Every deadline the checkpoint gate reports comes from the job's DateProvider, but the gate compared them against Date.now(). The e2e provider runs at a fixed offset from wall clock, so a held block could be certified as having budget left in a slot the proposer had already spent — the guard passing exactly when it should fail. The schedule now exposes the job's own clock and every budget, next-sub-slot and block-count answer defaults to it. The watchdog still measures real elapsed time, since it guards the test process rather than the protocol. An unsigned anvil_reorg replacement also has to carry its gas limit. Anvil estimates when none is given, and it estimates against the chain before the rollback: the Inbox sends these tests replay are cheap there because their bucket is already open, and far more expensive once re-mined into cold state. Measured against a local anvil: with the limit serialized the replacement declares the 900000 it asked for, without it anvil substitutes its own 36492 estimate.
Both reorg cases paused production, sent their messages, armed the gate and restarted wherever the sends had left the clock. Resuming late in a build frame leaves one sub-slot, so the block that first consumes the messages is the checkpoint's final block rather than a standalone one: the gate skips it, an unheld checkpoint consumes and publishes the messages, and the reorg then invalidates a publication nobody was holding. In the observed run that took the held block down with it and republished its checkpoint at a later slot. They now wait for the next build window with the fixture's existing timetable helper, which waits in real time rather than warping, so the first checkpoint after the pinned parent is the one held. The placement-only case also asserts that premise directly — the held checkpoint is exactly the published parent's successor, and nothing published into the reorg window — so a recurrence fails as a premise rather than as a confusing downstream identity mismatch.
…ble to attest The case names a non-proposer validator and requires its signature on the replacement checkpoint, but picked it by node index. The wide-slot profile seats 3 of the 4 registered validators, so that choice was not guaranteed to be in the committee at all — a missing signature would have been a setup artefact rather than a protocol result. The case now seats all four and picks the named validator from the committee the rollup reports for the held slot, checks it is still seated at the replacement slot, and measures quorum against that committee rather than against the node count. The signature itself is still recovered from its attestation over the published archive.
…f helper stops the sequencers The check ran after waitForProvenCheckpoint returned, and that helper stops every sequencer: a proposer interrupted mid-build reports 'Sequencer was interrupted' two milliseconds later, so the assertion could only ever observe teardown noise. It now asserts on the failures the scenario itself produced, snapshotted before the stop, and requires the abandoned slot's streaming abort to be present exactly once rather than merely permitted.
…ords it The abort presence check was made against the generic sequencer failure list, which never carries checkpoint-build-aborted: the fixture's watcher registers a fixed set of failure events and this one is not among them, which is why the scenario collects it separately in the first place. The assertion could not have passed even on a healthy run. It now counts the scenario's own abort collector, and pins all four identifying fields — the abandoned slot, its checkpoint, the proposer that gave it up, and the inbox_prefix_reorged reason — so a coincidental abort elsewhere cannot satisfy it.
A node pointed at an RPC provider that refuses log queries wider than N blocks had no way to say so: each L1 log query picked its own range, and the only defence was the reactive bisection in fetchLogsBisectingRange, which costs a failed request per split and only covers the call sites that opt into it. MAX_L1_LOGS_WINDOW_SIZE (default 10000) now bounds the span of every eth_getLogs the node issues. The cap lives in the transport that makeL1HttpTransport builds, which every L1 client is constructed with, so it applies to getLogs, getContractEvents and a contract's getEvents alike without each call site having to know about it. A wider range is split into consecutive windows and their logs concatenated; a window the provider rejects fails the whole request, so a partial prefix is never reported as the range's logs. Moving block tags are resolved only when a range has to be split, so a query that already fits keeps its tag and costs no extra round trip. Also migrates prover-stats to the shared transport -- it built its own fallback and so queried checkpoint logs outside the cap -- and stops wrapL1RpcTransport from dropping request's options argument.
|
| return forward(args); | ||
| } | ||
|
|
||
| const window = await resolveLogsWindow(filter, forward); |
There was a problem hiding this comment.
For a fitting range such as fromBlock: 20, toBlock: "latest", this resolves latest with eth_getBlockByNumber before checking whether splitting is necessary. Recurring log queries therefore incur an extra RPC round trip, and an error from that preliminary lookup causes an otherwise valid eth_getLogs request to fail.
| return forward(args); | ||
| } | ||
|
|
||
| const filter = (args.params as [LogsFilter] | undefined)?.[0]; |
There was a problem hiding this comment.
The new handler asserts the shapes of RPC parameters and responses instead of narrowing or validating them, including casting params to LogsFilter and the provider result to unknown[]. This violates the repository directive to prefer type guards over as Type casts and can turn malformed RPC data into an opaque runtime error. The same assertion pattern also appears in resolveLogsWindow and the new tests. This repository requirement must be satisfied before merging.
Context Used: yarn-project/CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| logger.debug( | ||
| `Splitting eth_getLogs over L1 blocks ${window.fromBlock}-${window.toBlock} into ${windows.length} requests of up to ${maxWindowSize} blocks`, | ||
| ); |
There was a problem hiding this comment.
Unstructured split-query logging
This debug call interpolates every range detail into the message without passing a structured context object. That violates the repository directive requiring indexed context fields as the second argument to log calls, so operators cannot reliably filter these events by range, window count, or configured cap. This repository requirement must be satisfied before merging.
Context Used: yarn-project/CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
55c9f56 to
0478e6b
Compare
Stacked on #243.
A node pointed at an RPC provider that refuses log queries wider than N blocks has no way to say so today. Each L1 log query picks its own range —
MESSAGE_SENT_SEARCH_WINDOW_BLOCKS(100), the registry's hardcoded 1000-block scan,watchContractEvent's 100, the archiver's batch-derived spans, andretrieveL2ProofVerifiedEvents, which asks forfromBlock→latestwith no upper bound at all. The only defence is the reactive bisection infetchLogsBisectingRange, which costs a failed request per split and only covers the call sites that opt into it.MAX_L1_LOGS_WINDOW_SIZE(default10000) now bounds the span of everyeth_getLogsthe node issues.Where the cap lives
In the transport
makeL1HttpTransportbuilds. Every L1 client in the node —getPublicClient,createExtendedL1Client, and the ones the archiver, aztec-node, prover-node, epoch-cache and blob-client construct themselves — is built on that transport, so the cap applies togetLogs,getContractEventsand a contract'sgetEventsalike, without each call site having to know about it. It also means an operator whose provider caps ranges below what a call site asks for does not have to find and configure that call site.A range wider than the cap is split into consecutive windows and their logs concatenated. A window the provider rejects fails the whole request: a partial prefix must never be reported as the range's logs.
Block tags
Moving tags (
latest,safe,finalized) are resolved only when a range actually has to be split, so a query that already fits keeps its tag and costs no extra round trip.pendinghas no canonical height and a tag the provider cannot answer leaves the bound unknown; both pass through unchunked rather than being given a height they do not have.Consistency
Splitting means several views of the chain rather than one: a reorg between two windows can yield logs that never coexisted on one chain. That is already true of the chunking at several existing call sites, and consumers that commit results to disk check the L1 blocks they came from.
Also here
prover-statsbuilt its ownfallbacktransport and so queried checkpoint logs outside the cap; it now uses the shared one.wrapL1RpcTransportdroppedrequest's second argument, including per-call retry overrides. Fixed.The existing stricter chunkers (100 in
watchContractEvent, 1000 in the registry) are unchanged — the cap is a ceiling, and it subdivides them if an operator sets it lower.Testing
logs_window.test.tscovers the tiling (cap of 1, cap+1, exact multiples, inverted ranges), tag resolution and the pass-through cases, filter preservation, and that a rejected window fails the whole request instead of returning the windows before it.client.test.tsadds an end-to-end pair against a local endpoint that rejects ranges over 10 blocks: the query fails with a 100-block window and succeeds, returning all three windows' logs through viem's own decoding, with the cap at 10.