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 @@ -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';
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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...');
Expand All @@ -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);
Expand Down
39 changes: 21 additions & 18 deletions yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() {
Expand Down
45 changes: 44 additions & 1 deletion yarn-project/end-to-end/src/e2e_p2p/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
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,
Expand Down
Loading