Skip to content
Open
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 @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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...');
Expand All @@ -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()}`);
Expand Down
55 changes: 54 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,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,
Expand Down
Loading