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
4 changes: 4 additions & 0 deletions spartan/aztec-node/templates/_pod-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ spec:
- name: SLASH_DUPLICATE_PROPOSAL_PENALTY
value: {{ .Values.node.slash.duplicateProposalPenalty | quote }}
{{- end }}
{{- if .Values.node.slash.duplicateAttestationPenalty }}
- name: SLASH_DUPLICATE_ATTESTATION_PENALTY
value: {{ .Values.node.slash.duplicateAttestationPenalty | quote }}
{{- end }}
{{- if .Values.node.slash.attestDescendantOfInvalidPenalty }}
- name: SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY
value: {{ .Values.node.slash.attestDescendantOfInvalidPenalty | quote }}
Expand Down
5 changes: 5 additions & 0 deletions spartan/environments/network-defaults.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ slasher: &slasher
SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 10e18
# Penalty for proposing two different block or checkpoint proposal for the same position.
SLASH_DUPLICATE_PROPOSAL_PENALTY: 10e18
# Penalty for signing attestations for different proposals at the same slot.
SLASH_DUPLICATE_ATTESTATION_PENALTY: 10e18
# Penalty for unknown offenses.
SLASH_UNKNOWN_PENALTY: 10e18
# Penalty for broadcasting an invalid block.
Expand Down Expand Up @@ -240,6 +242,7 @@ networks:
SLASH_INACTIVITY_PENALTY: 10e18
SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY: 10e18
SLASH_DUPLICATE_PROPOSAL_PENALTY: 10e18
SLASH_DUPLICATE_ATTESTATION_PENALTY: 10e18
SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 10e18
SLASH_UNKNOWN_PENALTY: 10e18
SLASH_INVALID_BLOCK_PENALTY: 10e18
Expand Down Expand Up @@ -278,6 +281,7 @@ networks:
SLASH_INACTIVITY_PENALTY: 10e18
SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY: 10e18
SLASH_DUPLICATE_PROPOSAL_PENALTY: 10e18
SLASH_DUPLICATE_ATTESTATION_PENALTY: 10e18
SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 10e18
SLASH_UNKNOWN_PENALTY: 10e18
SLASH_INVALID_BLOCK_PENALTY: 10e18
Expand Down Expand Up @@ -334,6 +338,7 @@ networks:
SLASH_INACTIVITY_PENALTY: 2000e18
SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY: 2000e18
SLASH_DUPLICATE_PROPOSAL_PENALTY: 2000e18
SLASH_DUPLICATE_ATTESTATION_PENALTY: 2000e18
SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 2000e18
SLASH_UNKNOWN_PENALTY: 2000e18
SLASH_INVALID_BLOCK_PENALTY: 2000e18
Expand Down
1 change: 1 addition & 0 deletions spartan/scripts/deploy_network.sh
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ SLASH_PRUNE_PENALTY = ${SLASH_PRUNE_PENALTY:-null}
SLASH_DATA_WITHHOLDING_PENALTY = ${SLASH_DATA_WITHHOLDING_PENALTY:-null}
SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY = ${SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY:-null}
SLASH_DUPLICATE_PROPOSAL_PENALTY = ${SLASH_DUPLICATE_PROPOSAL_PENALTY:-null}
SLASH_DUPLICATE_ATTESTATION_PENALTY = ${SLASH_DUPLICATE_ATTESTATION_PENALTY:-null}
SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY = ${SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY:-null}
SLASH_UNKNOWN_PENALTY = ${SLASH_UNKNOWN_PENALTY:-null}
SLASH_INVALID_BLOCK_PENALTY = ${SLASH_INVALID_BLOCK_PENALTY:-null}
Expand Down
1 change: 1 addition & 0 deletions spartan/terraform/deploy-aztec-infra/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ locals {
"validator.slash.dataWithholdingPenalty" = var.SLASH_DATA_WITHHOLDING_PENALTY
"validator.slash.proposeInvalidAttestationsPenalty" = var.SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY
"validator.slash.duplicateProposalPenalty" = var.SLASH_DUPLICATE_PROPOSAL_PENALTY
"validator.slash.duplicateAttestationPenalty" = var.SLASH_DUPLICATE_ATTESTATION_PENALTY
"validator.slash.attestDescendantOfInvalidPenalty" = var.SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY
"validator.slash.unknownPenalty" = var.SLASH_UNKNOWN_PENALTY
"validator.slash.invalidBlockPenalty" = var.SLASH_INVALID_BLOCK_PENALTY
Expand Down
6 changes: 6 additions & 0 deletions spartan/terraform/deploy-aztec-infra/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,12 @@ variable "SLASH_DUPLICATE_PROPOSAL_PENALTY" {
nullable = true
}

variable "SLASH_DUPLICATE_ATTESTATION_PENALTY" {
description = "The slash duplicate attestation penalty"
type = string
nullable = true
}

variable "SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY" {
description = "The slash attest descendant of invalid penalty"
type = string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import type { AztecNodeService } from '@aztec/aztec-node';
import type { TestAztecNodeService } from '@aztec/aztec-node/test';
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 { jest } from '@jest/globals';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { privateKeyToAccount } from 'viem/accounts';

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';

const TEST_TIMEOUT = 600_000; // 10 minutes

jest.setTimeout(TEST_TIMEOUT);

const NUM_VALIDATORS = 4;
const BOOT_NODE_UDP_PORT = 4600;
const COMMITTEE_SIZE = NUM_VALIDATORS;
const ETHEREUM_SLOT_DURATION = 8;
const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * 3;
const BLOCK_DURATION = 4;

const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicate-attestation-slash-'));

/**
* Test that slashing occurs when a validator sends duplicate attestations (equivocation).
*
* The setup of the test is as follows:
* 1. Create 4 validator nodes total:
* - 2 honest validators with unique keys
* - 2 "malicious proposer" validators that share the SAME validator key but have DIFFERENT coinbase addresses
* (these will create duplicate proposals for the same slot)
* - The malicious proposer validators also have `attestToEquivocatedProposals: true` which makes them attest
* to BOTH proposals when they receive them - this is the attestation equivocation we want to detect
* 2. The two nodes with the same proposer key will both detect they are proposers for the same slot and race to propose
* 3. Since they have different coinbase addresses, their proposals will have different archives (different content)
* 4. The malicious attester nodes (with attestToEquivocatedProposals enabled) will attest to BOTH proposals
* 5. Honest validators will detect the duplicate attestations and emit a slash event
*
* NOTE: This test triggers BOTH duplicate proposal (from malicious proposers sharing a key) AND duplicate attestation
* (from the malicious proposers attesting to multiple proposals). We verify specifically that the duplicate
* attestation offense is recorded.
*/
describe('e2e_p2p_duplicate_attestation_slash', () => {
let t: P2PNetworkTest;
let nodes: AztecNodeService[];

// Small slashing unit so we don't kick anyone out
const slashingUnit = BigInt(1e14);
const slashingQuorum = 3;
const slashingRoundSize = 4;
const aztecEpochDuration = 2;

beforeEach(async () => {
t = await P2PNetworkTest.create({
testName: 'e2e_p2p_duplicate_attestation_slash',
numberOfNodes: 0,
numberOfValidators: NUM_VALIDATORS,
basePort: BOOT_NODE_UDP_PORT,
metricsPort: shouldCollectMetrics(),
initialConfig: {
listenAddress: '127.0.0.1',
aztecEpochDuration,
ethereumSlotDuration: ETHEREUM_SLOT_DURATION,
aztecSlotDuration: AZTEC_SLOT_DURATION,
aztecTargetCommitteeSize: COMMITTEE_SIZE,
aztecProofSubmissionEpochs: 1024, // effectively do not reorg
slashInactivityConsecutiveEpochThreshold: 32, // effectively do not slash for inactivity
minTxsPerBlock: 0, // always be building
mockGossipSubNetwork: true, // do not worry about p2p connectivity issues
slashingQuorum,
slashingRoundSizeInEpochs: slashingRoundSize / aztecEpochDuration,
slashAmountSmall: slashingUnit,
slashAmountMedium: slashingUnit * 2n,
slashAmountLarge: slashingUnit * 3n,
enforceTimeTable: true,
blockDurationMs: BLOCK_DURATION * 1000,
slashDuplicateProposalPenalty: slashingUnit,
slashDuplicateAttestationPenalty: slashingUnit,
slashingOffsetInRounds: 1,
},
});

await t.setup();
await t.applyBaseSetup();
});

afterEach(async () => {
await t.stopNodes(nodes);
await t.teardown();
for (let i = 0; i < NUM_VALIDATORS; i++) {
fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true, maxRetries: 3 });
}
});

const debugRollup = async () => {
await t.ctx.cheatCodes.rollup.debugRollup();
};

it('slashes validator who sends duplicate attestations', async () => {
const { rollup } = await t.getContracts();

// Jump forward to an epoch in the future such that the validator set is not empty
await t.ctx.cheatCodes.rollup.advanceToEpoch(EpochNumber(4));
await debugRollup();

t.logger.warn('Creating nodes');

// Get the attester private key that will be shared between two malicious proposer nodes
// We'll use validator index 0 for the "malicious" proposer validator key
const maliciousProposerIndex = 0;
const maliciousProposerPrivateKey = getPrivateKeyFromIndex(
ATTESTER_PRIVATE_KEYS_START_INDEX + maliciousProposerIndex,
)!;
const maliciousProposerAddress = EthAddress.fromString(
privateKeyToAccount(`0x${maliciousProposerPrivateKey.toString('hex')}`).address,
);

t.logger.warn(`Malicious proposer address: ${maliciousProposerAddress.toString()}`);

// Create two nodes with the SAME validator key but DIFFERENT coinbase addresses
// This will cause them to create proposals with different content for the same slot
// Additionally, enable attestToEquivocatedProposals so they will attest to BOTH proposals
const maliciousProposerPrivateKeyHex = bufferToHex(maliciousProposerPrivateKey);
const coinbase1 = EthAddress.random();
const coinbase2 = EthAddress.random();

t.logger.warn(`Creating malicious proposer node 1 with coinbase ${coinbase1.toString()}`);
const maliciousNode1 = await createNode(
{
...t.ctx.aztecNodeConfig,
validatorPrivateKey: maliciousProposerPrivateKeyHex,
coinbase: coinbase1,
attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations
broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals
},
t.ctx.dateProvider!,
BOOT_NODE_UDP_PORT + 1,
t.bootstrapNodeEnr,
maliciousProposerIndex,
t.prefilledPublicData,
`${DATA_DIR}-0`,
shouldCollectMetrics(),
);

t.logger.warn(`Creating malicious proposer node 2 with coinbase ${coinbase2.toString()}`);
const maliciousNode2 = await createNode(
{
...t.ctx.aztecNodeConfig,
validatorPrivateKey: maliciousProposerPrivateKeyHex,
coinbase: coinbase2,
attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations
broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals
},
t.ctx.dateProvider!,
BOOT_NODE_UDP_PORT + 2,
t.bootstrapNodeEnr,
maliciousProposerIndex,
t.prefilledPublicData,
`${DATA_DIR}-1`,
shouldCollectMetrics(),
);

// 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.dateProvider!,
BOOT_NODE_UDP_PORT + 3,
t.bootstrapNodeEnr,
1,
t.prefilledPublicData,
`${DATA_DIR}-2`,
shouldCollectMetrics(),
);
const honestNode2 = await createNode(
t.ctx.aztecNodeConfig,
t.ctx.dateProvider!,
BOOT_NODE_UDP_PORT + 4,
t.bootstrapNodeEnr,
2,
t.prefilledPublicData,
`${DATA_DIR}-3`,
shouldCollectMetrics(),
);

nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2];

// Wait for P2P mesh and the committee to be fully formed before proceeding
await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS);
await awaitCommitteeExists({ rollup, logger: t.logger });

// 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)
// The malicious proposer nodes also create duplicate attestations (attestToEquivocatedProposals enabled)
t.logger.warn('Waiting for duplicate attestation offense to be detected...');
const offenses = await awaitOffenseDetected({
epochDuration: t.ctx.aztecNodeConfig.aztecEpochDuration,
logger: t.logger,
nodeAdmin: honestNode1, // Use honest node to check for offenses
slashingRoundSize,
waitUntilOffenseCount: 2, // Wait for both duplicate proposal and duplicate attestation
timeoutSeconds: AZTEC_SLOT_DURATION * 16,
});

t.logger.warn(`Collected offenses`, { offenses });

// Verify we have detected the duplicate attestation offense
const duplicateAttestationOffenses = offenses.filter(
offense => offense.offenseType === OffenseType.DUPLICATE_ATTESTATION,
);
const duplicateProposalOffenses = offenses.filter(
offense => offense.offenseType === OffenseType.DUPLICATE_PROPOSAL,
);

t.logger.info(`Found ${duplicateAttestationOffenses.length} duplicate attestation offenses`);
t.logger.info(`Found ${duplicateProposalOffenses.length} duplicate proposal offenses`);

// We should have at least one duplicate attestation offense
expect(duplicateAttestationOffenses.length).toBeGreaterThan(0);

// Verify the duplicate attestation offense is from the malicious proposer address
// (since they are the ones with attestToEquivocatedProposals enabled)
for (const offense of duplicateAttestationOffenses) {
expect(offense.offenseType).toEqual(OffenseType.DUPLICATE_ATTESTATION);
expect(offense.validator.toString()).toEqual(maliciousProposerAddress.toString());
}

// 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);
t.logger.info(`Offense slot ${offenseSlot}: committee includes attester ${maliciousProposerAddress.toString()}`);
expect(committeeInfo.committee?.map(addr => addr.toString())).toContain(maliciousProposerAddress.toString());
}

t.logger.warn('Duplicate attestation offense correctly detected and recorded');
});
});
1 change: 1 addition & 0 deletions yarn-project/foundation/src/config/env_var.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export type EnvVar =
| 'SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD'
| 'SLASH_INVALID_BLOCK_PENALTY'
| 'SLASH_DUPLICATE_PROPOSAL_PENALTY'
| 'SLASH_DUPLICATE_ATTESTATION_PENALTY'
| 'SLASH_OVERRIDE_PAYLOAD'
| 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY'
| 'SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY'
Expand Down
10 changes: 10 additions & 0 deletions yarn-project/p2p/src/client/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ReqRespSubProtocolValidators,
} from '../services/reqresp/interface.js';
import type {
DuplicateAttestationInfo,
DuplicateProposalInfo,
P2PBlockReceivedCallback,
P2PCheckpointReceivedCallback,
Expand Down Expand Up @@ -90,6 +91,15 @@ export type P2P<T extends P2PClientType = P2PClientType.Full> = P2PApiFull<T> &
*/
registerDuplicateProposalCallback(callback: (info: DuplicateProposalInfo) => void): void;

/**
* Registers a callback invoked when a duplicate attestation is detected (equivocation).
* A validator signing attestations for different proposals at the same slot.
* The callback is triggered on the first duplicate (when count goes from 1 to 2).
*
* @param callback - Function called with info about the duplicate attestation
*/
registerDuplicateAttestationCallback(callback: (info: DuplicateAttestationInfo) => void): void;

/**
* Request a list of transactions from another peer by their tx hashes.
* @param txHashes - Hashes of the txs to query.
Expand Down
19 changes: 16 additions & 3 deletions yarn-project/p2p/src/client/p2p_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from '../services/reqresp/interface.js';
import { chunkTxHashesRequest } from '../services/reqresp/protocols/tx.js';
import type {
DuplicateAttestationInfo,
DuplicateProposalInfo,
P2PBlockReceivedCallback,
P2PCheckpointReceivedCallback,
Expand Down Expand Up @@ -339,9 +340,17 @@ export class P2PClient<T extends P2PClientType = P2PClientType.Full>
public async broadcastProposal(proposal: BlockProposal): Promise<void> {
this.log.verbose(`Broadcasting proposal for slot ${proposal.slotNumber} to peers`);
// Store our own proposal so we can respond to req/resp requests for it
const { totalForPosition } = await this.attestationPool.tryAddBlockProposal(proposal);
if (totalForPosition > 1) {
throw new Error(`Attempted to broadcast a duplicate block proposal for slot ${proposal.slotNumber}`);
const { count } = await this.attestationPool.tryAddBlockProposal(proposal);
if (count > 1) {
if (this.config.broadcastEquivocatedProposals) {
this.log.warn(`Broadcasting equivocated block proposal for slot ${proposal.slotNumber}`, {
slot: proposal.slotNumber,
archive: proposal.archive.toString(),
count,
});
} else {
throw new Error(`Attempted to broadcast a duplicate block proposal for slot ${proposal.slotNumber}`);
}
}
return this.p2pService.propagate(proposal);
}
Expand Down Expand Up @@ -393,6 +402,10 @@ export class P2PClient<T extends P2PClientType = P2PClientType.Full>
this.p2pService.registerDuplicateProposalCallback(callback);
}

public registerDuplicateAttestationCallback(callback: (info: DuplicateAttestationInfo) => void): void {
this.p2pService.registerDuplicateAttestationCallback(callback);
}

/**
* Uses the batched Request Response protocol to request a set of transactions from the network.
*/
Expand Down
Loading
Loading