diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts index 2f68d908d458..3ebef6ac94da 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts @@ -4,6 +4,7 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { bufferToHex } from '@aztec/foundation/string'; import { OffenseType } from '@aztec/slasher'; +import { TopicType } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; import fs from 'fs'; @@ -15,7 +16,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 { awaitCommitteeExists, awaitEpochWithProposer, awaitOffenseDetected } from './shared.js'; const TEST_TIMEOUT = 600_000; // 10 minutes @@ -141,6 +142,7 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { coinbase: coinbase1, attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 1, @@ -159,6 +161,7 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { coinbase: coinbase2, attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 2, @@ -172,7 +175,10 @@ describe('e2e_p2p_duplicate_attestation_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, @@ -182,7 +188,10 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { shouldCollectMetrics(), ); const honestNode2 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 4, t.bootstrapNodeEnr, @@ -194,10 +203,27 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - // Wait for P2P mesh and the committee to be fully formed before proceeding - await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); + // Wait for P2P mesh on all needed topics before starting sequencers + await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS, 30, 0.1, [ + TopicType.tx, + TopicType.block_proposal, + TopicType.checkpoint_proposal, + ]); await awaitCommitteeExists({ rollup, logger: t.logger }); + // Advance to an epoch where the malicious proposer is selected + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + await awaitEpochWithProposer({ + epochCache, + cheatCodes: t.ctx.cheatCodes.rollup, + targetProposer: maliciousProposerAddress, + logger: t.logger, + }); + + // Start all sequencers simultaneously + t.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + // Wait for offenses to be detected // We expect BOTH duplicate proposal AND duplicate attestation offenses // The malicious proposer nodes create duplicate proposals (same key, different coinbase) @@ -236,7 +262,6 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { } // Verify that for each duplicate attestation offense, the attester for that slot is the malicious validator - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; for (const offense of duplicateAttestationOffenses) { const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); const committeeInfo = await epochCache.getCommittee(offenseSlot); 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..c0b6062acac6 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 @@ -4,6 +4,7 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { bufferToHex } from '@aztec/foundation/string'; import { OffenseType } from '@aztec/slasher'; +import { TopicType } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; import fs from 'fs'; @@ -15,7 +16,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 { awaitCommitteeExists, awaitEpochWithProposer, awaitOffenseDetected } from './shared.js'; const TEST_TIMEOUT = 600_000; // 10 minutes @@ -130,6 +131,7 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase1, broadcastEquivocatedProposals: true, + dontStartSequencer: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 1, @@ -147,6 +149,7 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase2, broadcastEquivocatedProposals: true, + dontStartSequencer: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 2, @@ -160,7 +163,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 +176,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, @@ -182,10 +191,27 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - // Wait for P2P mesh and the committee to be fully formed before proceeding - await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); + // Wait for P2P mesh on all needed topics before starting sequencers + await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS, 30, 0.1, [ + TopicType.tx, + TopicType.block_proposal, + TopicType.checkpoint_proposal, + ]); await awaitCommitteeExists({ rollup, logger: t.logger }); + // Advance to an epoch where the malicious proposer is selected + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + await awaitEpochWithProposer({ + epochCache, + cheatCodes: t.ctx.cheatCodes.rollup, + targetProposer: maliciousValidatorAddress, + logger: t.logger, + }); + + // Start all sequencers simultaneously + t.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + // 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...'); @@ -208,7 +234,6 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { } // 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) { const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot); diff --git a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts index 1f2120f28177..83e18d1bc4ea 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts @@ -408,6 +408,7 @@ export class P2PNetworkTest { expectedNodeCount?: number, timeoutSeconds = 30, checkIntervalSeconds = 0.1, + topics: TopicType[] = [TopicType.tx], ) { const nodeCount = expectedNodeCount ?? nodes.length; const minPeerCount = nodeCount - 1; @@ -434,26 +435,28 @@ export class P2PNetworkTest { this.logger.warn('All nodes connected to P2P mesh'); - // Wait for GossipSub mesh to form for the tx topic. + // Wait for GossipSub mesh to form for all specified topics. // We only require at least 1 mesh peer per node because GossipSub // stops grafting once it reaches Dlo peers and won't fill the mesh to all available peers. - this.logger.warn('Waiting for GossipSub mesh to form for tx topic...'); - await Promise.all( - nodes.map(async (node, index) => { - const p2p = node.getP2P(); - await retryUntil( - async () => { - const meshPeers = await p2p.getGossipMeshPeerCount(TopicType.tx); - this.logger.debug(`Node ${index} has ${meshPeers} gossip mesh peers for tx topic`); - return meshPeers >= 1 ? true : undefined; - }, - `Node ${index} to have gossip mesh peers for tx topic`, - timeoutSeconds, - checkIntervalSeconds, - ); - }), - ); - this.logger.warn('All nodes have gossip mesh peers for tx topic'); + for (const topic of topics) { + this.logger.warn(`Waiting for GossipSub mesh to form for ${topic} topic...`); + await Promise.all( + nodes.map(async (node, index) => { + const p2p = node.getP2P(); + await retryUntil( + async () => { + const meshPeers = await p2p.getGossipMeshPeerCount(topic); + this.logger.debug(`Node ${index} has ${meshPeers} gossip mesh peers for ${topic} topic`); + return meshPeers >= 1 ? true : undefined; + }, + `Node ${index} to have gossip mesh peers for ${topic} topic`, + timeoutSeconds, + checkIntervalSeconds, + ); + }), + ); + this.logger.warn(`All nodes have gossip mesh peers for ${topic} topic`); + } } async teardown() { 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..656313537ec8 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,48 @@ 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. + * With N validators and M slots per epoch, a specific proposer may not be selected in any given epoch. + * For example, with 4 validators and 2 slots/epoch, there is about a 44% chance per epoch. + */ +export async function awaitEpochWithProposer({ + epochCache, + cheatCodes, + targetProposer, + logger, + maxAttempts = 20, +}: { + epochCache: EpochCacheInterface; + cheatCodes: RollupCheatCodes; + targetProposer: EthAddress; + logger: Logger; + maxAttempts?: number; +}): Promise { + const { epochDuration } = await cheatCodes.getConfig(); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const currentEpoch = await cheatCodes.getEpoch(); + const startSlot = Number(currentEpoch) * Number(epochDuration); + const endSlot = startSlot + Number(epochDuration); + + logger.info(`Checking epoch ${currentEpoch} (slots ${startSlot}-${endSlot - 1}) for proposer ${targetProposer}`); + + 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 ${currentEpoch}`); + return; + } + } + + logger.info(`Target proposer not found in epoch ${currentEpoch}, 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,