Skip to content

Commit e90fd6d

Browse files
committed
feat(slasher): add duplicate proposal slashing
1 parent 9e1d53c commit e90fd6d

34 files changed

+2142
-721
lines changed
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import type { AztecNodeService } from '@aztec/aztec-node';
2+
import type { TestAztecNodeService } from '@aztec/aztec-node/test';
3+
import { EthAddress } from '@aztec/aztec.js/addresses';
4+
import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
5+
import { bufferToHex } from '@aztec/foundation/string';
6+
import { OffenseType } from '@aztec/slasher';
7+
8+
import { jest } from '@jest/globals';
9+
import fs from 'fs';
10+
import os from 'os';
11+
import path from 'path';
12+
import { privateKeyToAccount } from 'viem/accounts';
13+
14+
import { shouldCollectMetrics } from '../fixtures/fixtures.js';
15+
import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNode } from '../fixtures/setup_p2p_test.js';
16+
import { getPrivateKeyFromIndex } from '../fixtures/utils.js';
17+
import { P2PNetworkTest } from './p2p_network.js';
18+
import { awaitCommitteeExists, awaitOffenseDetected } from './shared.js';
19+
20+
const TEST_TIMEOUT = 600_000; // 10 minutes
21+
22+
jest.setTimeout(TEST_TIMEOUT);
23+
24+
const NUM_VALIDATORS = 4;
25+
const BOOT_NODE_UDP_PORT = 4500;
26+
const COMMITTEE_SIZE = NUM_VALIDATORS;
27+
const ETHEREUM_SLOT_DURATION = 8;
28+
const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * 3;
29+
const BLOCK_DURATION = 4;
30+
31+
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicate-proposal-slash-'));
32+
33+
/**
34+
* Test that slashing occurs when a validator sends duplicate proposals (equivocation).
35+
*
36+
* The setup of the test is as follows:
37+
* 1. Create 4 validator nodes total:
38+
* - 2 honest validators with unique keys
39+
* - 2 "malicious" validators that share the SAME validator key but have DIFFERENT coinbase addresses
40+
* 2. The two nodes with the same key will both detect they are proposers for the same slot and naturally race to propose
41+
* 3. Since they have different coinbase addresses, their proposals will have different archives (different content)
42+
* 4. Other validators will detect the duplicate and emit a slash event
43+
*/
44+
describe('e2e_p2p_duplicate_proposal_slash', () => {
45+
let t: P2PNetworkTest;
46+
let nodes: AztecNodeService[];
47+
48+
// Small slashing unit so we don't kick anyone out
49+
const slashingUnit = BigInt(1e14);
50+
const slashingQuorum = 3;
51+
const slashingRoundSize = 4;
52+
const aztecEpochDuration = 2;
53+
54+
beforeEach(async () => {
55+
t = await P2PNetworkTest.create({
56+
testName: 'e2e_p2p_duplicate_proposal_slash',
57+
numberOfNodes: 0,
58+
numberOfValidators: NUM_VALIDATORS,
59+
basePort: BOOT_NODE_UDP_PORT,
60+
metricsPort: shouldCollectMetrics(),
61+
initialConfig: {
62+
listenAddress: '127.0.0.1',
63+
aztecEpochDuration,
64+
ethereumSlotDuration: ETHEREUM_SLOT_DURATION,
65+
aztecSlotDuration: AZTEC_SLOT_DURATION,
66+
aztecTargetCommitteeSize: COMMITTEE_SIZE,
67+
aztecProofSubmissionEpochs: 1024, // effectively do not reorg
68+
slashInactivityConsecutiveEpochThreshold: 32, // effectively do not slash for inactivity
69+
minTxsPerBlock: 0, // always be building
70+
mockGossipSubNetwork: true, // do not worry about p2p connectivity issues
71+
slashingQuorum,
72+
slashingRoundSizeInEpochs: slashingRoundSize / aztecEpochDuration,
73+
slashAmountSmall: slashingUnit,
74+
slashAmountMedium: slashingUnit * 2n,
75+
slashAmountLarge: slashingUnit * 3n,
76+
enforceTimeTable: true,
77+
blockDurationMs: BLOCK_DURATION * 1000,
78+
slashDuplicateProposalPenalty: slashingUnit,
79+
slashingOffsetInRounds: 1,
80+
},
81+
});
82+
83+
await t.setup();
84+
await t.applyBaseSetup();
85+
});
86+
87+
afterEach(async () => {
88+
await t.stopNodes(nodes);
89+
await t.teardown();
90+
for (let i = 0; i < NUM_VALIDATORS; i++) {
91+
fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true, maxRetries: 3 });
92+
}
93+
});
94+
95+
const debugRollup = async () => {
96+
await t.ctx.cheatCodes.rollup.debugRollup();
97+
};
98+
99+
it('slashes validator who sends duplicate proposals', async () => {
100+
const { rollup } = await t.getContracts();
101+
102+
// Jump forward to an epoch in the future such that the validator set is not empty
103+
await t.ctx.cheatCodes.rollup.advanceToEpoch(EpochNumber(4));
104+
await debugRollup();
105+
106+
t.logger.warn('Creating nodes');
107+
108+
// Get the attester private key that will be shared between two malicious nodes
109+
// We'll use validator index 0 for the "malicious" validator key
110+
const maliciousValidatorIndex = 0;
111+
const maliciousValidatorPrivateKey = getPrivateKeyFromIndex(
112+
ATTESTER_PRIVATE_KEYS_START_INDEX + maliciousValidatorIndex,
113+
)!;
114+
const maliciousValidatorAddress = EthAddress.fromString(
115+
privateKeyToAccount(`0x${maliciousValidatorPrivateKey.toString('hex')}`).address,
116+
);
117+
118+
t.logger.warn(`Malicious proposer address: ${maliciousValidatorAddress.toString()}`);
119+
120+
// Create two nodes with the SAME validator key but DIFFERENT coinbase addresses
121+
// This will cause them to create proposals with different content for the same slot
122+
const maliciousPrivateKeyHex = bufferToHex(maliciousValidatorPrivateKey);
123+
const coinbase1 = EthAddress.random();
124+
const coinbase2 = EthAddress.random();
125+
126+
t.logger.warn(`Creating malicious node 1 with coinbase ${coinbase1.toString()}`);
127+
const maliciousNode1 = await createNode(
128+
{ ...t.ctx.aztecNodeConfig, validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase1 },
129+
t.ctx.dateProvider!,
130+
BOOT_NODE_UDP_PORT + 1,
131+
t.bootstrapNodeEnr,
132+
maliciousValidatorIndex,
133+
t.prefilledPublicData,
134+
`${DATA_DIR}-0`,
135+
shouldCollectMetrics(),
136+
);
137+
138+
t.logger.warn(`Creating malicious node 2 with coinbase ${coinbase2.toString()}`);
139+
const maliciousNode2 = await createNode(
140+
{ ...t.ctx.aztecNodeConfig, validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase2 },
141+
t.ctx.dateProvider!,
142+
BOOT_NODE_UDP_PORT + 2,
143+
t.bootstrapNodeEnr,
144+
maliciousValidatorIndex,
145+
t.prefilledPublicData,
146+
`${DATA_DIR}-1`,
147+
shouldCollectMetrics(),
148+
);
149+
150+
// Create honest nodes with unique validator keys (indices 1 and 2)
151+
t.logger.warn('Creating honest nodes');
152+
const honestNode1 = await createNode(
153+
t.ctx.aztecNodeConfig,
154+
t.ctx.dateProvider!,
155+
BOOT_NODE_UDP_PORT + 3,
156+
t.bootstrapNodeEnr,
157+
1,
158+
t.prefilledPublicData,
159+
`${DATA_DIR}-2`,
160+
shouldCollectMetrics(),
161+
);
162+
const honestNode2 = await createNode(
163+
t.ctx.aztecNodeConfig,
164+
t.ctx.dateProvider!,
165+
BOOT_NODE_UDP_PORT + 4,
166+
t.bootstrapNodeEnr,
167+
2,
168+
t.prefilledPublicData,
169+
`${DATA_DIR}-3`,
170+
shouldCollectMetrics(),
171+
);
172+
173+
nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2];
174+
175+
// Wait for P2P mesh and the committee to be fully formed before proceeding
176+
await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS);
177+
await awaitCommitteeExists({ rollup, logger: t.logger });
178+
179+
// Wait for offense to be detected
180+
// The honest nodes should detect the duplicate proposal from the malicious validator
181+
t.logger.warn('Waiting for duplicate proposal offense to be detected...');
182+
const offenses = await awaitOffenseDetected({
183+
epochDuration: t.ctx.aztecNodeConfig.aztecEpochDuration,
184+
logger: t.logger,
185+
nodeAdmin: honestNode1, // Use honest node to check for offenses
186+
slashingRoundSize,
187+
waitUntilOffenseCount: 1,
188+
timeoutSeconds: AZTEC_SLOT_DURATION * 16,
189+
});
190+
191+
t.logger.warn(`Collected offenses`, { offenses });
192+
193+
// Verify the offense is correct
194+
expect(offenses.length).toBeGreaterThan(0);
195+
for (const offense of offenses) {
196+
expect(offense.offenseType).toEqual(OffenseType.DUPLICATE_PROPOSAL);
197+
expect(offense.validator.toString()).toEqual(maliciousValidatorAddress.toString());
198+
}
199+
200+
// Verify that for each offense, the proposer for that slot is the malicious validator
201+
const epochCache = (honestNode1 as TestAztecNodeService).epochCache;
202+
for (const offense of offenses) {
203+
const offenseSlot = SlotNumber(Number(offense.epochOrSlot));
204+
const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot);
205+
t.logger.info(`Offense slot ${offenseSlot}: proposer is ${proposerForSlot?.toString()}`);
206+
expect(proposerForSlot?.toString()).toEqual(maliciousValidatorAddress.toString());
207+
}
208+
209+
t.logger.warn('Duplicate proposal offense correctly detected and recorded');
210+
});
211+
});

yarn-project/end-to-end/src/e2e_p2p/shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export async function awaitCommitteeExists({
145145
'non-empty committee',
146146
60,
147147
);
148+
logger.warn(`Committee has been formed`, { committee: committee!.map(c => c.toString()) });
148149
return committee!.map(c => c.toString() as `0x${string}`);
149150
}
150151

yarn-project/end-to-end/src/fixtures/setup_p2p_test.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,17 @@ export async function createNodes(
8383
return nodes;
8484
}
8585

86-
/** Creates a P2P enabled instance of Aztec Node Service with a validator */
86+
/** Extended config type for createNode with test-specific overrides. */
87+
export type CreateNodeConfig = AztecNodeConfig & {
88+
/** Whether to skip starting the sequencer. */
89+
dontStartSequencer?: boolean;
90+
/** Override the private key (instead of deriving from addressIndex). */
91+
validatorPrivateKey?: `0x${string}`;
92+
};
93+
94+
/** Creates a P2P enabled instance of Aztec Node Service with a validator. */
8795
export async function createNode(
88-
config: AztecNodeConfig & { dontStartSequencer?: boolean },
96+
config: CreateNodeConfig,
8997
dateProvider: DateProvider,
9098
tcpPort: number,
9199
bootstrapNode: string | undefined,
@@ -187,20 +195,21 @@ export async function createP2PConfig(
187195
}
188196

189197
export async function createValidatorConfig(
190-
config: AztecNodeConfig,
198+
config: CreateNodeConfig,
191199
bootstrapNodeEnr?: string,
192200
port?: number,
193201
addressIndex: number | number[] = 1,
194202
dataDirectory?: string,
195203
) {
196204
const addressIndices = Array.isArray(addressIndex) ? addressIndex : [addressIndex];
197-
if (addressIndices.length === 0) {
205+
if (addressIndices.length === 0 && !config.validatorPrivateKey) {
198206
throw new Error('At least one address index must be provided to create a validator config');
199207
}
200208

201-
const attesterPrivateKeys = addressIndices.map(index =>
202-
bufferToHex(getPrivateKeyFromIndex(ATTESTER_PRIVATE_KEYS_START_INDEX + index)!),
203-
);
209+
// Use override private key if provided, otherwise derive from address indices
210+
const attesterPrivateKeys = config.validatorPrivateKey
211+
? [config.validatorPrivateKey]
212+
: addressIndices.map(index => bufferToHex(getPrivateKeyFromIndex(ATTESTER_PRIVATE_KEYS_START_INDEX + index)!));
204213
const p2pConfig = await createP2PConfig(config, bootstrapNodeEnr, port, dataDirectory);
205214
const nodeConfig: AztecNodeConfig = {
206215
...config,

yarn-project/foundation/src/config/env_var.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ export type EnvVar =
218218
| 'SLASH_INACTIVITY_TARGET_PERCENTAGE'
219219
| 'SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD'
220220
| 'SLASH_INVALID_BLOCK_PENALTY'
221+
| 'SLASH_DUPLICATE_PROPOSAL_PENALTY'
221222
| 'SLASH_OVERRIDE_PAYLOAD'
222223
| 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY'
223224
| 'SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY'

yarn-project/p2p/src/client/interface.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import type {
1313
ReqRespSubProtocolHandler,
1414
ReqRespSubProtocolValidators,
1515
} from '../services/reqresp/interface.js';
16-
import type { P2PBlockReceivedCallback, P2PCheckpointReceivedCallback } from '../services/service.js';
16+
import type {
17+
DuplicateProposalInfo,
18+
P2PBlockReceivedCallback,
19+
P2PCheckpointReceivedCallback,
20+
} from '../services/service.js';
1721

1822
/**
1923
* Enum defining the possible states of the p2p client.
@@ -78,6 +82,14 @@ export type P2P<T extends P2PClientType = P2PClientType.Full> = P2PApiFull<T> &
7882
*/
7983
registerCheckpointProposalHandler(callback: P2PCheckpointReceivedCallback): void;
8084

85+
/**
86+
* Registers a callback invoked when a duplicate proposal is detected (equivocation).
87+
* The callback is triggered on the first duplicate (when count goes from 1 to 2).
88+
*
89+
* @param callback - Function called with info about the duplicate proposal
90+
*/
91+
registerDuplicateProposalCallback(callback: (info: DuplicateProposalInfo) => void): void;
92+
8193
/**
8294
* Request a list of transactions from another peer by their tx hashes.
8395
* @param txHashes - Hashes of the txs to query.

yarn-project/p2p/src/client/p2p_client.test.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -415,25 +415,22 @@ describe('P2P Client', () => {
415415

416416
describe('Attestation pool pruning', () => {
417417
it('deletes attestations for finalized blocks', async () => {
418-
const deleteCheckpointAttestationsOlderThanSpy = jest.spyOn(
419-
attestationPool,
420-
'deleteCheckpointAttestationsOlderThan',
421-
);
418+
const deleteOlderThanSpy = jest.spyOn(attestationPool, 'deleteOlderThan');
422419

423420
blockSource.setProvenBlockNumber(0);
424421
await client.start();
425-
expect(deleteCheckpointAttestationsOlderThanSpy).not.toHaveBeenCalled();
422+
expect(deleteOlderThanSpy).not.toHaveBeenCalled();
426423

427424
await advanceToProvenBlock(BlockNumber(10));
428-
expect(deleteCheckpointAttestationsOlderThanSpy).not.toHaveBeenCalled();
425+
expect(deleteOlderThanSpy).not.toHaveBeenCalled();
429426

430427
await advanceToFinalizedBlock(BlockNumber(10));
431-
expect(deleteCheckpointAttestationsOlderThanSpy).toHaveBeenCalledTimes(1);
432-
expect(deleteCheckpointAttestationsOlderThanSpy).toHaveBeenCalledWith(SlotNumber(10));
428+
expect(deleteOlderThanSpy).toHaveBeenCalledTimes(1);
429+
expect(deleteOlderThanSpy).toHaveBeenCalledWith(SlotNumber(10));
433430

434431
await advanceToFinalizedBlock(BlockNumber(15));
435-
expect(deleteCheckpointAttestationsOlderThanSpy).toHaveBeenCalledTimes(2);
436-
expect(deleteCheckpointAttestationsOlderThanSpy).toHaveBeenCalledWith(SlotNumber(15));
432+
expect(deleteOlderThanSpy).toHaveBeenCalledTimes(2);
433+
expect(deleteOlderThanSpy).toHaveBeenCalledWith(SlotNumber(15));
437434
});
438435
});
439436

yarn-project/p2p/src/client/p2p_client.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ import {
3939
type ReqRespSubProtocolValidators,
4040
} from '../services/reqresp/interface.js';
4141
import { chunkTxHashesRequest } from '../services/reqresp/protocols/tx.js';
42-
import type { P2PBlockReceivedCallback, P2PCheckpointReceivedCallback, P2PService } from '../services/service.js';
42+
import type {
43+
DuplicateProposalInfo,
44+
P2PBlockReceivedCallback,
45+
P2PCheckpointReceivedCallback,
46+
P2PService,
47+
} from '../services/service.js';
4348
import { TxCollection } from '../services/tx_collection/tx_collection.js';
4449
import { TxProvider } from '../services/tx_provider.js';
4550
import { type P2P, P2PClientState, type P2PSyncState } from './interface.js';
@@ -329,7 +334,7 @@ export class P2PClient<T extends P2PClientType = P2PClientType.Full>
329334
public async broadcastProposal(proposal: BlockProposal): Promise<void> {
330335
this.log.verbose(`Broadcasting proposal for slot ${proposal.slotNumber} to peers`);
331336
// Store our own proposal so we can respond to req/resp requests for it
332-
await this.attestationPool.addBlockProposal(proposal);
337+
await this.attestationPool.tryAddBlockProposal(proposal);
333338
return this.p2pService.propagate(proposal);
334339
}
335340

@@ -371,6 +376,10 @@ export class P2PClient<T extends P2PClientType = P2PClientType.Full>
371376
this.p2pService.registerCheckpointReceivedCallback(handler);
372377
}
373378

379+
public registerDuplicateProposalCallback(callback: (info: DuplicateProposalInfo) => void): void {
380+
this.p2pService.registerDuplicateProposalCallback(callback);
381+
}
382+
374383
/**
375384
* Uses the batched Request Response protocol to request a set of transactions from the network.
376385
*/
@@ -735,7 +744,7 @@ export class P2PClient<T extends P2PClientType = P2PClientType.Full>
735744
await this.txPool.deleteTxs(txHashes, { permanently: true });
736745
await this.txPool.cleanupDeletedMinedTxs(lastBlockNum);
737746

738-
await this.attestationPool.deleteCheckpointAttestationsOlderThan(lastBlockSlot);
747+
await this.attestationPool.deleteOlderThan(lastBlockSlot);
739748

740749
this.log.debug(`Synched to finalized block ${lastBlockNum} at slot ${lastBlockSlot}`);
741750
}

yarn-project/p2p/src/client/test/tx_proposal_collector/proposal_tx_collector_worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ process.on('message', (msg: WorkerCommand) => {
301301
throw new Error('Attestation pool not initialized');
302302
}
303303
const proposal = deserializeBlockProposal(msg.blockProposal);
304-
await attestationPool.addBlockProposal(proposal);
304+
await attestationPool.tryAddBlockProposal(proposal);
305305
await sendMessage({ type: 'BLOCK_PROPOSAL_SET', requestId, archiveRoot: proposal.archive.toString() });
306306
break;
307307
}

0 commit comments

Comments
 (0)