Skip to content

Commit 65dee1a

Browse files
authored
test: stabilize invalid checkpoint descendant e2e (#23582)
Fixes the invalid checkpoint descendant e2e timing by keeping sequencers stopped until the test has selected adjacent target proposers, installed listeners, applied malicious configs, and warped to the intended pipelined build window. This avoids applying malicious config to an earlier slot owned by the same validator, which is what caused the CI run for PR #23502 to miss the intended P1/P2 checkpoint pair.
1 parent 7137a68 commit 65dee1a

1 file changed

Lines changed: 51 additions & 36 deletions

File tree

yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts

Lines changed: 51 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -495,41 +495,44 @@ describe('e2e_epochs/epochs_invalidate_block', () => {
495495
minTxsPerBlock: 0,
496496
}),
497497
);
498-
await Promise.all(sequencers.map(s => s.start()));
499-
logger.warn(`Started all sequencers, waiting for first checkpoint before applying malicious config`);
500-
501-
// Wait for at least one good checkpoint to be mined so any in-progress slot has completed.
502-
const initialCheckpointNumber = (await nodes[0].getChainTips()).checkpointed.checkpoint.number;
503-
await test.waitUntilCheckpointNumber(CheckpointNumber(initialCheckpointNumber + 1), test.L2_SLOT_DURATION_IN_S * 4);
504-
505-
// Align to the start of an L2 slot, then pick two slots with a 3-slot gap so the malicious
506-
// config has time to land on each proposer's job snapshot under pipelining, and P1's proposal
507-
// has time to propagate to P2 before P2 starts pipelined building.
508-
await test.monitor.waitUntilNextL2Slot();
509-
const { l2SlotNumber: currentSlot } = await test.monitor.run();
510-
logger.warn(`First checkpoint mined, current slot is ${currentSlot}`);
511-
512-
let badSlot1 = SlotNumber.add(currentSlot, 3);
513-
let badSlot2 = SlotNumber.add(currentSlot, 4);
514-
let p1Proposer = await test.epochCache.getProposerAttesterAddressInSlot(badSlot1);
515-
let p2Proposer = await test.epochCache.getProposerAttesterAddressInSlot(badSlot2);
516-
517-
// Ensure the two slots belong to different proposers; retry by walking forward one slot at
518-
// a time. With committee size 6 and random shuffling this should usually succeed first try.
519-
let attempts = 0;
520-
while (p1Proposer && p2Proposer && p1Proposer.equals(p2Proposer)) {
521-
attempts += 1;
522-
if (attempts > 6) {
523-
throw new Error(`Could not find two consecutive slots with different proposers`);
498+
let badSlot1: SlotNumber | undefined;
499+
let p1Proposer: EthAddress | undefined;
500+
let p2Proposer: EthAddress | undefined;
501+
let candidate = Number(test.epochCache.getEpochAndSlotNow().slot) + 4;
502+
const maxAttempts = 200;
503+
for (let attempt = 0; attempt < maxAttempts && badSlot1 === undefined; attempt++) {
504+
try {
505+
const [p1, p2] = await Promise.all([
506+
test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate)),
507+
test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate + 1)),
508+
]);
509+
if (p1 && p2 && !p1.equals(p2)) {
510+
badSlot1 = SlotNumber(candidate);
511+
p1Proposer = p1;
512+
p2Proposer = p2;
513+
break;
514+
}
515+
candidate++;
516+
} catch (err) {
517+
const msg = err instanceof Error ? err.message : String(err);
518+
if (!msg.includes('EpochNotStable')) {
519+
throw err;
520+
}
521+
const block = await test.l1Client.getBlock({ includeTransactions: false });
522+
const warpBy = test.epochDuration * test.L2_SLOT_DURATION_IN_S;
523+
const newTs = Number(block.timestamp) + warpBy;
524+
logger.warn(`Hit EpochNotStable at candidate ${candidate}, warping L1 forward by ${warpBy}s to ${newTs}`);
525+
await test.context.cheatCodes.eth.warp(newTs, { resetBlockInterval: true });
526+
const newCurrentSlot = Number(test.epochCache.getEpochAndSlotNow().slot);
527+
if (candidate < newCurrentSlot + 4) {
528+
candidate = newCurrentSlot + 4;
529+
}
524530
}
525-
badSlot1 = SlotNumber.add(badSlot1, 1);
526-
badSlot2 = SlotNumber.add(badSlot2, 1);
527-
p1Proposer = await test.epochCache.getProposerAttesterAddressInSlot(badSlot1);
528-
p2Proposer = await test.epochCache.getProposerAttesterAddressInSlot(badSlot2);
529531
}
530-
if (!p1Proposer || !p2Proposer) {
531-
throw new Error(`Could not resolve proposers for slots ${badSlot1} and ${badSlot2}`);
532+
if (badSlot1 === undefined || !p1Proposer || !p2Proposer) {
533+
throw new Error(`Could not find two consecutive slots with different proposers after ${maxAttempts} attempts`);
532534
}
535+
const badSlot2 = SlotNumber.add(badSlot1, 1);
533536

534537
const p1NodeIndex = nodes.findIndex(n => n.getSequencer()!.validatorAddresses!.some(a => a.equals(p1Proposer!)));
535538
const p2NodeIndex = nodes.findIndex(n => n.getSequencer()!.validatorAddresses!.some(a => a.equals(p2Proposer!)));
@@ -580,10 +583,6 @@ describe('e2e_epochs/epochs_invalidate_block', () => {
580583

581584
observerArchiver.events.on(L2BlockSourceEvents.DescendentOfInvalidAttestationsCheckpointDetected, onDescendant);
582585

583-
// Send a couple of txs so there's content for both checkpoints.
584-
logger.warn('Sending transactions to fill the bad checkpoints');
585-
await Promise.all(times(4, i => testContract.methods.emit_nullifier(BigInt(i + 1)).send({ from, wait: NO_WAIT })));
586-
587586
// Watch for both CheckpointProposed events at the targeted slots.
588587
const p1CheckpointPromise = promiseWithResolvers<CheckpointNumber>();
589588
const p2CheckpointPromise = promiseWithResolvers<CheckpointNumber>();
@@ -596,6 +595,22 @@ describe('e2e_epochs/epochs_invalidate_block', () => {
596595
}
597596
});
598597

598+
// Send a couple of txs so there's content for both checkpoints.
599+
logger.warn('Sending transactions to fill the bad checkpoints');
600+
await Promise.all(times(4, i => testContract.methods.emit_nullifier(BigInt(i + 1)).send({ from, wait: NO_WAIT })));
601+
602+
// Sequencers are still stopped. Warp to the L1 block immediately before the pipelined build
603+
// window for P1, so the first proposer job that can observe the malicious config is the
604+
// intended checkpoint, not an earlier slot owned by the same validator.
605+
const buildSlot = SlotNumber.add(badSlot1, -1);
606+
const buildSlotStart = getTimestampForSlot(buildSlot, test.constants);
607+
const warpTo = buildSlotStart - BigInt(test.L1_BLOCK_TIME_IN_S);
608+
logger.warn(`Warping L1 to timestamp ${warpTo} (one L1 block before build slot ${buildSlot})`);
609+
await test.context.cheatCodes.eth.warp(Number(warpTo), { resetBlockInterval: true });
610+
611+
await Promise.all(sequencers.map(s => s.start()));
612+
logger.warn(`Started all sequencers after warping to the target build window`);
613+
599614
logger.warn(`Waiting for two checkpoints to be mined on slots ${badSlot1} and ${badSlot2}`);
600615
const [p1Checkpoint, p2Checkpoint] = await executeTimeout(
601616
() => Promise.all([p1CheckpointPromise.promise, p2CheckpointPromise.promise]),

0 commit comments

Comments
 (0)