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);