diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts index 374e4527d4ef..f4afea0a9ff3 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts @@ -15,7 +15,7 @@ import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNode } from '../fixtures/setup_p2p_test.js'; import { getPrivateKeyFromIndex } from '../fixtures/utils.js'; import { P2PNetworkTest } from './p2p_network.js'; -import { awaitCommitteeExists, awaitOffenseDetected } from './shared.js'; +import { advanceToEpochBeforeProposer, awaitCommitteeExists, awaitOffenseDetected } from './shared.js'; const TEST_TIMEOUT = 600_000; // 10 minutes @@ -130,6 +130,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase1, broadcastEquivocatedProposals: true, + dontStartSequencer: true, + // Prevent HA peer proposals from being added to the archiver, so both + // malicious nodes build their own blocks instead of one yielding to the other. + skipPushProposedBlocksToArchiver: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 1, @@ -147,6 +151,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase2, broadcastEquivocatedProposals: true, + dontStartSequencer: true, + // Prevent HA peer proposals from being added to the archiver, so both + // malicious nodes build their own blocks instead of one yielding to the other. + skipPushProposedBlocksToArchiver: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 2, @@ -160,7 +168,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { // Create honest nodes with unique validator keys (indices 1 and 2) t.logger.warn('Creating honest nodes'); const honestNode1 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 3, t.bootstrapNodeEnr, @@ -170,7 +181,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { shouldCollectMetrics(), ); const honestNode2 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 4, t.bootstrapNodeEnr, @@ -186,6 +200,24 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); await awaitCommitteeExists({ rollup, logger: t.logger }); + // Find an epoch where the malicious proposer is selected, stopping one epoch before + // so we have time to start sequencers before the target epoch arrives + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + const { targetEpoch } = await advanceToEpochBeforeProposer({ + epochCache, + cheatCodes: t.ctx.cheatCodes.rollup, + targetProposer: maliciousValidatorAddress, + logger: t.logger, + }); + + // Start all sequencers while still one epoch before the target + t.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + + // Now warp to the target epoch — sequencers are already running + t.logger.warn(`Advancing to target epoch ${targetEpoch}`); + await t.ctx.cheatCodes.rollup.advanceToEpoch(targetEpoch); + // Wait for offense to be detected // The honest nodes should detect the duplicate proposal from the malicious validator t.logger.warn('Waiting for duplicate proposal offense to be detected...'); @@ -200,16 +232,17 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { t.logger.warn(`Collected offenses`, { offenses }); - // Verify the offense is correct - expect(offenses.length).toBeGreaterThan(0); - for (const offense of offenses) { - expect(offense.offenseType).toEqual(OffenseType.DUPLICATE_PROPOSAL); + // Filter to only DUPLICATE_PROPOSAL offenses. The two malicious nodes sharing the same key + // will also each self-attest to their own (different) checkpoint proposals, which causes honest + // nodes to detect a DUPLICATE_ATTESTATION as well. We only care about proposals here. + const proposalOffenses = offenses.filter(o => o.offenseType === OffenseType.DUPLICATE_PROPOSAL); + expect(proposalOffenses.length).toBeGreaterThan(0); + for (const offense of proposalOffenses) { expect(offense.validator.toString()).toEqual(maliciousValidatorAddress.toString()); } // Verify that for each offense, the proposer for that slot is the malicious validator - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; - for (const offense of offenses) { + for (const offense of proposalOffenses) { const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot); t.logger.info(`Offense slot ${offenseSlot}: proposer is ${proposerForSlot?.toString()}`); diff --git a/yarn-project/end-to-end/src/e2e_p2p/shared.ts b/yarn-project/end-to-end/src/e2e_p2p/shared.ts index a74488fef1c0..1a4b2746614f 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/shared.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/shared.ts @@ -6,12 +6,13 @@ import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import { TxHash } from '@aztec/aztec.js/tx'; import type { RollupCheatCodes } from '@aztec/aztec/testing'; +import type { EpochCacheInterface } from '@aztec/epoch-cache'; import type { EmpireSlashingProposerContract, RollupContract, TallySlashingProposerContract, } from '@aztec/ethereum/contracts'; -import { EpochNumber } from '@aztec/foundation/branded-types'; +import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { timesAsync, unique } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { retryUntil } from '@aztec/foundation/retry'; @@ -150,6 +151,58 @@ export async function awaitCommitteeExists({ return committee!.map(c => c.toString() as `0x${string}`); } +/** + * Advance epochs until we find one where the target proposer is selected for at least one slot, + * then stop one epoch before it. This leaves time for the caller to start sequencers before + * warping to the target epoch, avoiding the race where the target epoch passes before sequencers + * are ready. + * + * Returns the target epoch number so the caller can warp to it after starting sequencers. + */ +export async function advanceToEpochBeforeProposer({ + epochCache, + cheatCodes, + targetProposer, + logger, + maxAttempts = 20, +}: { + epochCache: EpochCacheInterface; + cheatCodes: RollupCheatCodes; + targetProposer: EthAddress; + logger: Logger; + maxAttempts?: number; +}): Promise<{ targetEpoch: EpochNumber }> { + const { epochDuration } = await cheatCodes.getConfig(); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const currentEpoch = await cheatCodes.getEpoch(); + // Check the NEXT epoch's slots so we stay one epoch before the target, + // giving the caller time to start sequencers before the target epoch arrives. + const nextEpoch = Number(currentEpoch) + 1; + const startSlot = nextEpoch * Number(epochDuration); + const endSlot = startSlot + Number(epochDuration); + + logger.info( + `Checking next epoch ${nextEpoch} (slots ${startSlot}-${endSlot - 1}) for proposer ${targetProposer} (current epoch: ${currentEpoch})`, + ); + + for (let s = startSlot; s < endSlot; s++) { + const proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(s)); + if (proposer && proposer.equals(targetProposer)) { + logger.warn( + `Found target proposer ${targetProposer} in slot ${s} of epoch ${nextEpoch}. Staying at epoch ${currentEpoch} to allow sequencer startup.`, + ); + return { targetEpoch: EpochNumber(nextEpoch) }; + } + } + + logger.info(`Target proposer not found in epoch ${nextEpoch}, advancing to next epoch`); + await cheatCodes.advanceToNextEpoch(); + } + + throw new Error(`Target proposer ${targetProposer} not found in any slot after ${maxAttempts} epoch attempts`); +} + export async function awaitOffenseDetected({ logger, nodeAdmin,