Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ describe('e2e_escape_hatch_vote_only', () => {
minTxsPerBlock: 0,
enforceTimeTable: true,
automineL1Setup: true,
// Pipelining opts — exercise the §6 B5 fix (tryVoteWhenEscapeHatchOpen signing/submitting for targetSlot).
// inboxLag: 2 so the sequencer sources L1->L2 messages from a sealed checkpoint when building for slot+1.
enableProposerPipelining: true,
inboxLag: 2,
});

({
Expand Down Expand Up @@ -142,19 +146,38 @@ describe('e2e_escape_hatch_vote_only', () => {
afterEach(() => teardown());

it('casts governance signals and advances checkpoints while escape hatch is closed', async () => {
const sequencer = sequencerClient!.getSequencer();

// Enable voting from the sequencer.
await aztecNodeAdmin!.setConfig({
governanceProposerPayload: newGovernanceProposerPayloadAddress,
minTxsPerBlock: 0,
});

// Set up event listeners to track sequencer behavior
// We need to set it for hatch 1, and then make a time jump. We do this such that we don't pollute the epoch cache.
// The warp must happen before we attach failure-event listeners, because any checkpoint proposal in flight at warp
// time will fail (its propose tx becomes invalid after the L1 timestamp jump) — that is a test-setup artifact, not
// a behavior we are asserting on.
if (OPEN_THE_HATCH) {
await ethCheatCodes.store(
await rollup.getEscapeHatchAddress(),
ethCheatCodes.keccak256(BigInt(EscapeHatchStorage.find(s => s.label === '$designatedProposer')!.slot), 1n),
escapeHatchProposerAddress.toField().toBigInt(),
);
expect(await rollup.isEscapeHatchOpen(EpochNumber(Number(ESCAPE_HATCH_FREQUENCY)))).toBeTruthy();

logger.info(`Advancing to epoch ${ESCAPE_HATCH_FREQUENCY}`);

await cheatCodes.rollup.advanceToEpoch(EpochNumber(Number(ESCAPE_HATCH_FREQUENCY)), {
offset: -ETHEREUM_SLOT_DURATION,
});
}

// Set up event listeners to track sequencer behavior during the vote-only window
const failEvents: Array<{ type: keyof SequencerEvents; args: any }> = [];
const blockProposedEvents: Array<{ blockNumber: any; slot: any }> = [];
const checkpointPublishedEvents: Array<{ checkpoint: any; slot: any }> = [];

const sequencer = sequencerClient!.getSequencer();

// Track failure events that indicate problems
const failEventTypes: (keyof SequencerEvents)[] = [
'block-build-failed',
Expand Down Expand Up @@ -192,22 +215,6 @@ describe('e2e_escape_hatch_vote_only', () => {
logger.warn(`Sequencer published checkpoint when escape hatch should be open`, args);
});

// We need to set it for hatch 1, and then make a time jump. We do this such that we don't pollute the epoch cache
if (OPEN_THE_HATCH) {
await ethCheatCodes.store(
await rollup.getEscapeHatchAddress(),
ethCheatCodes.keccak256(BigInt(EscapeHatchStorage.find(s => s.label === '$designatedProposer')!.slot), 1n),
escapeHatchProposerAddress.toField().toBigInt(),
);
expect(await rollup.isEscapeHatchOpen(EpochNumber(Number(ESCAPE_HATCH_FREQUENCY)))).toBeTruthy();

logger.info(`Advancing to epoch ${ESCAPE_HATCH_FREQUENCY}`);

await cheatCodes.rollup.advanceToEpoch(EpochNumber(Number(ESCAPE_HATCH_FREQUENCY)), {
offset: -ETHEREUM_SLOT_DURATION,
});
}

const getStats = async () => ({
slot: await rollup.getSlotNumber(),
epoch: await rollup.getEpochNumberForSlotNumber(await rollup.getSlotNumber()),
Expand All @@ -229,20 +236,37 @@ describe('e2e_escape_hatch_vote_only', () => {
1,
);

const finalStats = await getStats();

// Due to the the stats not being pulled at the same time, a vote could land after the slot is fetched, but before the votes are.
// Therefore, we use the slots passed as the lower bound.
const slotsPassed = finalStats.slot - initialStats.slot;
// Snapshot the slot we will assert against now; under proposer pipelining the sequencer signs a vote in build
// slot N for target slot N+1 and submits it at the start of N+1, so the votes corresponding to slots up through
// `slotAtMeasurement` lag the current slot by one. Wait for the L1 slot to advance one more so the last
// in-flight vote (signed for `slotAtMeasurement`) has time to mine before we count votes.
const slotAtMeasurement = await rollup.getSlotNumber();
const slotsPassed = slotAtMeasurement - initialStats.slot;
expect(slotsPassed).toBeGreaterThan(0);
const drainTarget = slotAtMeasurement + 2;
await retryUntil(
() => rollup.getSlotNumber().then(s => s >= drainTarget),
'pipelined vote drain',
AZTEC_SLOT_DURATION * 4,
1,
);

const finalStats = await getStats();
expect(finalStats.votes - initialStats.votes).toBeGreaterThanOrEqual(slotsPassed);
if (OPEN_THE_HATCH) {
expect(finalStats.pending - initialStats.pending).toBe(0);

// When escape hatch is open, sequencer should only vote, not build blocks nor checkpoints, but there should also be no failures.
expect(blockProposedEvents).toEqual([]);
expect(failEvents).toEqual([]);
expect(checkpointPublishedEvents).toEqual([]);
// Filter out events corresponding to pre-warp slots — they are checkpoint proposals that were in flight when
// the test warped past their target slot and whose L1 propose tx then fails. That's a setup artifact of the
// warp, not behavior we are asserting on in the vote-only window.
const inVoteOnlyWindow = <T extends { slot?: any; args?: { slot?: any } }>(e: T) => {
const slotValue = (e as any).slot ?? (e as any).args?.slot;
return slotValue === undefined || Number(slotValue) >= Number(initialStats.slot);
};
expect(blockProposedEvents.filter(inVoteOnlyWindow)).toEqual([]);
expect(failEvents.filter(inVoteOnlyWindow)).toEqual([]);
expect(checkpointPublishedEvents.filter(inVoteOnlyWindow)).toEqual([]);
} else {
expect(finalStats.pending - initialStats.pending).toBeGreaterThanOrEqual(slotsPassed);
}
Expand Down
34 changes: 23 additions & 11 deletions yarn-project/sequencer-client/src/sequencer/sequencer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
this.setState(SequencerState.PROPOSER_CHECK, slot);
const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
if (canPropose) {
await this.tryVoteWhenEscapeHatchOpen({ slot, proposer });
await this.tryVoteWhenEscapeHatchOpen({ slot, targetSlot, proposer });
} else {
this.log.trace(`Escape hatch open but we are not proposer, skipping vote-only actions`, {
slot,
Expand Down Expand Up @@ -883,9 +883,10 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
@trackSpan('Sequencer.tryVoteWhenEscapeHatchOpen', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
protected async tryVoteWhenEscapeHatchOpen(args: {
slot: SlotNumber;
targetSlot: SlotNumber;
proposer: EthAddress | undefined;
}): Promise<void> {
const { slot, proposer } = args;
const { slot, targetSlot, proposer } = args;

// Prevent duplicate attempts in the same slot
if (this.lastSlotForFallbackVote === slot) {
Expand All @@ -898,10 +899,19 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ

const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);

this.log.debug(`Escape hatch open for slot ${slot}, attempting vote-only actions`, { slot, attestorAddress });
this.log.debug(`Escape hatch open for slot ${slot}, attempting vote-only actions`, {
slot,
targetSlot,
attestorAddress,
});

// Under proposer pipelining, the multicall is expected to mine in `targetSlot` (slot + 1).
// Governance and slashing votes are EIP-712-signed against the slot they will mine in, and the
// L1 contract checks `msg.sender == getCurrentProposer()` using the mining slot. So we must
// sign for `targetSlot` and delay submission to the start of `targetSlot`. When pipelining is
// disabled `targetSlot == slot` and `sendRequestsAt` resolves with no extra sleep.
const voter = new CheckpointVoter(
slot,
targetSlot,
publisher,
attestorAddress,
this.validatorClient,
Expand All @@ -920,13 +930,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
return;
}

this.log.info(`Voting in slot ${slot} (escape hatch open)`, { slot });
// Votes are EIP-712-signed for `slot` (the slot in which the multicall is expected to mine).
// Without threading the slot through, bundleSimulate would override block.timestamp to the
// wall-clock current slot, which can be one L2 slot earlier than `slot`. The L1 contract
// then reads `signaler = getCurrentProposer()` against the wrong slot, so signature
// verification fails inside Multicall3 and every governance/slashing entry is dropped.
await publisher.sendRequestsAt(slot);
this.log.info(`Voting in slot ${slot} (escape hatch open)`, { slot, targetSlot });
// Votes are EIP-712-signed for `targetSlot`. Delay submission to the start of `targetSlot` so
// the multicall mines in the slot the votes were signed for; otherwise the L1 contract reads
// `signaler = getCurrentProposer()` against the wrong slot and signature verification fails
// silently inside Multicall3. Fire-and-forget so we don't block the sequencer's work loop while
// waiting for the target slot to start, mirroring tryVoteWhenSyncFails.
void publisher.sendRequestsAt(targetSlot).catch(err => {
this.log.error(`Failed to publish escape-hatch votes for slot ${slot}`, err, { slot, targetSlot });
});
}

/**
Expand Down
Loading