Skip to content

Commit 5dc3270

Browse files
authored
fix(sequencer): bind vote-only multicalls to target slot under pipelining (#23090)
Slashing votes are EIP-712-signed for `targetSlot` (the pipelined proposal slot, not the wall-clock slot) and submitted via Multicall3.aggregate3 with allowFailure: true. The contract verifies the signature against getCurrentSlot() derived from block.timestamp, so the multicall must mine in the slot the vote was signed for or the inner sub-call reverts silently and VoteCast is never emitted. Two paths in the sequencer were sending vote-only multicalls without delaying submission to the target-slot start: 1. CheckpointProposalJob.execute() if (!broadcast) branch — proposer enqueued votes but did not build a checkpoint. 2. Sequencer.tryVoteWhenSyncFails — proposer enqueued votes in a slot where archiver sync had not caught up. Both now route through `sendRequestsAt(getTimestampForSlot(targetSlot))` when proposer pipelining is enabled. The sync-failure path uses fire-and-forget so the wait does not block the sequencer's work loop.
1 parent 7513e78 commit 5dc3270

2 files changed

Lines changed: 26 additions & 4 deletions

File tree

yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,18 @@ export class CheckpointProposalJob implements Traceable {
198198

199199
if (!broadcast) {
200200
await Promise.all(votesPromises);
201-
// Still submit votes even without a checkpoint
201+
// Still submit votes even without a checkpoint.
202+
// Under proposer pipelining, vote-offenses signatures are EIP-712-bound to `targetSlot`
203+
// (the pipelined slot in which the multicall is expected to mine). Submitting at the
204+
// wall-clock time would let the multicall mine in a different L2 slot, causing
205+
// signature verification to fail silently inside Multicall3. Delay submission to the
206+
// start of `targetSlot` so the tx mines in the slot the vote was signed for.
202207
if (!this.config.fishermanMode) {
203-
this.pendingL1Submission = this.publisher.sendRequestsAt(this.dateProvider.nowAsDate()).then(() => {});
208+
const isPipelining = this.epochCache.isProposerPipeliningEnabled();
209+
const submitAfter = isPipelining
210+
? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000)
211+
: this.dateProvider.nowAsDate();
212+
this.pendingL1Submission = this.publisher.sendRequestsAt(submitAfter).then(() => {});
204213
}
205214
return undefined;
206215
}

yarn-project/sequencer-client/src/sequencer/sequencer.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { SlasherClientInterface } from '@aztec/slasher';
1414
import type { BlockData, L2BlockSink, L2BlockSource, ValidateCheckpointResult } from '@aztec/stdlib/block';
1515
import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint';
1616
import type { ChainConfig } from '@aztec/stdlib/config';
17-
import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
17+
import { getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
1818
import {
1919
type ResolvedSequencerConfig,
2020
type SequencerConfig,
@@ -785,7 +785,20 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
785785
}
786786

787787
this.log.info(`Voting in slot ${slot} despite sync failure`, { slot });
788-
await publisher.sendRequests();
788+
// Under proposer pipelining, votes are EIP-712-signed for `targetSlot` (the pipelined slot
789+
// in which the multicall is expected to mine). Submitting immediately would let the
790+
// multicall mine in the wall-clock slot, causing signature verification to fail silently
791+
// inside Multicall3. Delay submission to the start of `targetSlot` so the tx mines in the
792+
// slot the votes were signed for. We fire-and-forget so we don't block the sequencer's
793+
// work loop while waiting for the target slot to start.
794+
if (this.epochCache.isProposerPipeliningEnabled()) {
795+
const submitAfter = new Date(Number(getTimestampForSlot(targetSlot, this.l1Constants)) * 1000);
796+
void publisher.sendRequestsAt(submitAfter).catch(err => {
797+
this.log.error(`Failed to publish votes despite sync failure for slot ${slot}`, err, { slot });
798+
});
799+
} else {
800+
await publisher.sendRequests();
801+
}
789802
}
790803

791804
/**

0 commit comments

Comments
 (0)