From 785860b4356350ec2a336fadf26a00837eaf27a9 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 2 Jul 2026 21:41:49 -0300 Subject: [PATCH] fix(sequencer): gate standalone fallback prune on build-start deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proposer fallback `tryVoteAndPruneWhenCannotBuild` enqueued a standalone `prune` whenever it could propose, without checking the build-start deadline. On a transient pre-deadline sync miss (`checkSync` momentarily failing while the archiver caught up) the fallback fired a standalone prune that raced the imminent build — which bundles the prune inside `propose` on the happy path — and landed alone on L1, resetting the rollup checkpoint. Gate the standalone prune on `now > getBuildStartDeadline(targetSlot)` so it only fires once building the target-slot checkpoint is genuinely no longer possible. Votes still fire regardless of the deadline (kept from before, since under pipelining the target advances with the clock and gating votes on the deadline can miss the fallback window). Track votes and prune with separate per-slot guards (`lastSlotForFallbackVote` and `lastSlotForFallbackPrune`) so a pre-deadline vote pass no longer consumes the slot's later prune opportunity — otherwise a persistently-stuck proposer, which ticks pre-deadline first every slot, would be starved of the standalone prune that recovers a wedged pending chain. The prune block re-reads the clock so a deadline crossed during the vote/publisher awaits still prunes in the same work-loop iteration. --- .../src/sequencer/sequencer.test.ts | 43 +++++++ .../src/sequencer/sequencer.ts | 117 +++++++++++------- 2 files changed, 114 insertions(+), 46 deletions(-) diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index ffd7a9fc9ea1..8984621acc75 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -884,6 +884,49 @@ describe('sequencer', () => { ); }); + it('should not enqueue a standalone prune when sync fails before the build start deadline', async () => { + // Before the deadline the proposer still intends to build and would bundle any prune inside propose, + // so a transient sync miss here must not race that with a standalone prune. + const startDeadline = sequencer.getTimeTable().getBuildStartDeadline(SlotNumber(newSlotNumber)); + dateProvider.setTime((startDeadline - 1) * 1000); + + // Set us as the proposer and make the rollup prunable, so the only thing keeping prune from firing + // is the build-start deadline gate. + validatorClient.getValidatorAddresses.mockReturnValue([signer.address]); + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(signer.address); + publisher.enqueuePruneIfPrunable.mockResolvedValue(true); + + await sequencer.work(); + + expect(publisher.enqueuePruneIfPrunable).not.toHaveBeenCalled(); + }); + + it('should still enqueue a standalone prune post-deadline even after a pre-deadline vote pass in the same slot', async () => { + // A pre-deadline vote pass must not consume the slot's later prune opportunity: if sync stays broken + // and we cross the deadline within the same slot, the standalone prune (the stuck-chain recovery path) + // must still fire rather than being starved by the vote guard. + const startDeadline = sequencer.getTimeTable().getBuildStartDeadline(SlotNumber(newSlotNumber)); + + // Set us as the proposer, with a vote to cast pre-deadline and a prunable rollup. + validatorClient.getValidatorAddresses.mockReturnValue([signer.address]); + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(signer.address); + slasherClient.getProposerActions.mockResolvedValue(mockSlashActions); + publisher.enqueueSlashingActions.mockResolvedValue(true); + publisher.enqueuePruneIfPrunable.mockResolvedValue(true); + + // First tick, before the deadline: vote fallback runs, prune must not. + dateProvider.setTime((startDeadline - 1) * 1000); + await sequencer.work(); + expect(publisher.enqueueSlashingActions).toHaveBeenCalledTimes(1); + expect(publisher.enqueuePruneIfPrunable).not.toHaveBeenCalled(); + + // Second tick, same slot but now past the deadline: prune fires, votes are not re-cast. + dateProvider.setTime((startDeadline + 1) * 1000); + await sequencer.work(); + expect(publisher.enqueuePruneIfPrunable).toHaveBeenCalledWith(SlotNumber(newSlotNumber)); + expect(publisher.enqueueSlashingActions).toHaveBeenCalledTimes(1); + }); + it('should not vote when sync fails but not a proposer', async () => { // Set time past the start deadline for the target slot. const startDeadline = sequencer.getTimeTable().getBuildStartDeadline(SlotNumber(newSlotNumber)); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 19451a6f475d..1e66d336ae4c 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -88,8 +88,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter ({ [Attributes.SLOT_NUMBER]: slot })) protected async tryVoteAndPruneWhenCannotBuild(args: { slot: SlotNumber; targetSlot: SlotNumber }): Promise { const { slot, targetSlot } = args; - // Prevent duplicate attempts in the same slot - if (this.lastSlotForFallbackAction === slot) { - this.log.trace(`Already attempted fallback actions in slot ${slot} (skipping)`); - return; - } - - // Vote-only actions do not give up the slot: if sync recovers, a later work-loop iteration can still build. - // Under proposer pipelining the work loop reasons about the next L1 slot, so waiting for the target slot's - // build-start deadline can miss the whole fallback window when the target advances with the clock. + // Votes do not give up the slot: if sync recovers, a later work-loop iteration can still build. Under + // proposer pipelining the work loop reasons about the next L1 slot, so waiting for the target slot's + // build-start deadline can miss the whole fallback window when the target advances with the clock; votes + // therefore fire regardless of the deadline. The standalone prune is different: before the deadline the + // proposer still intends to build and would bundle the prune inside `propose`, so a pre-deadline standalone + // prune would race the imminent build and land alone (resetting the checkpoint). We only fall back to a + // standalone prune once building is no longer possible (past the build-start deadline), and track votes and + // prune with separate per-slot guards so a pre-deadline vote pass does not consume the slot's later prune. const nowSeconds = this.dateProvider.now() / 1000; const startDeadline = this.timetable.getBuildStartDeadline(targetSlot); + const pastBuildStartDeadline = nowSeconds > startDeadline; + + const shouldVote = this.lastSlotForFallbackVote !== slot; + const shouldPrune = pastBuildStartDeadline && this.lastSlotForFallbackPrune !== slot; + if (!shouldVote && !shouldPrune) { + this.log.trace(`Already attempted fallback actions in slot ${slot} (skipping)`, { slot, pastBuildStartDeadline }); + return; + } - this.log.trace(`Cannot build for slot ${slot}, checking for voting opportunities`, { + this.log.trace(`Cannot build for slot ${slot}, checking for fallback actions`, { nowSeconds, startDeadline, + shouldVote, + shouldPrune, }); // Check if we're a proposer or proposal is open const [canPropose, proposer] = await this.checkCanPropose(targetSlot); if (!canPropose) { - this.log.trace(`Cannot vote in slot ${slot} since we are not a proposer`, { slot, proposer }); + this.log.trace(`Cannot run fallback actions in slot ${slot} since we are not a proposer`, { slot, proposer }); return; } - // Mark this slot as attempted - this.lastSlotForFallbackAction = slot; - - // Get a publisher for voting + // Get a publisher for the fallback multicall const { attestorAddress, publisher } = await this.publisherFactory.create(proposer); - this.log.debug(`Attempting to vote despite sync failure at slot ${slot}`, { - attestorAddress, - slot, - }); - - // Enqueue governance and slashing votes (voter uses the target slot for L1 submission) - const voter = new CheckpointVoter( - targetSlot, - publisher, - attestorAddress, - this.validatorClient, - this.slasherClient, - this.l1Constants, - this.config, - this.metrics, - this.log, - ); - const votesPromises = voter.enqueueVotes(); - const votes = await Promise.all(votesPromises); + // Enqueue governance and slashing votes (voter uses the target slot for L1 submission). Mark the vote + // guard as soon as we attempt them so we don't re-cast within the same slot, even if there was nothing + // to vote on this tick. + let votes: (boolean | undefined)[] = []; + if (shouldVote) { + this.lastSlotForFallbackVote = slot; + this.log.debug(`Attempting to vote despite sync failure at slot ${slot}`, { attestorAddress, slot }); + const voter = new CheckpointVoter( + targetSlot, + publisher, + attestorAddress, + this.validatorClient, + this.slasherClient, + this.l1Constants, + this.config, + this.metrics, + this.log, + ); + votes = await Promise.all(voter.enqueueVotes()); + } - // Even if we cannot build, try to prune so a stuck pending chain (e.g. bad data blocking sync) can - // recover. prune() is permissionless, so it rides the same fallback multicall as the votes. - const pruneEnqueued = await this.tryEnqueuePruneIfPrunable(targetSlot, publisher); + // prune() is permissionless, so it rides the same fallback multicall as any votes. Mark the prune guard on + // attempt so we don't re-check prunability on every post-deadline tick. Re-read the clock here: the deadline + // may have passed during checkCanPropose / publisher creation / vote enqueueing above, in which case we can + // prune within this same invocation rather than waiting for the next work tick. + let pruneEnqueued = false; + if (this.dateProvider.now() / 1000 > startDeadline && this.lastSlotForFallbackPrune !== slot) { + this.lastSlotForFallbackPrune = slot; + pruneEnqueued = await this.tryEnqueuePruneIfPrunable(targetSlot, publisher); + } // Bail if nothing to do if (votes.every(p => !p) && !pruneEnqueued) { - this.log.debug(`Nothing to enqueue for slot ${slot} (no votes, not prunable)`); + this.log.debug(`Nothing to enqueue for slot ${slot} (no votes, not prunable)`, { slot, pastBuildStartDeadline }); return; } @@ -1016,6 +1039,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter { const { slot, targetSlot, proposer } = args; - // Prevent duplicate attempts in the same slot - if (this.lastSlotForFallbackAction === slot) { + // Prevent duplicate attempts in the same slot. Escape-hatch fallback is vote-only, so it shares the + // vote guard with tryVoteAndPruneWhenCannotBuild (the two paths are mutually exclusive per slot). + if (this.lastSlotForFallbackVote === slot) { this.log.trace(`Already attempted to vote in slot ${slot} (escape hatch open, skipping)`); return; } // Mark this slot as attempted - this.lastSlotForFallbackAction = slot; + this.lastSlotForFallbackVote = slot; const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);