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 @@ -253,6 +253,10 @@ spec:
- name: SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY
value: {{ .Values.node.slash.attestDescendantOfInvalidPenalty | quote }}
{{- end }}
{{- if .Values.node.slash.attestInvalidCheckpointProposalPenalty }}
- name: SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY
value: {{ .Values.node.slash.attestInvalidCheckpointProposalPenalty | quote }}
{{- end }}
{{- if .Values.node.slash.unknownPenalty }}
- name: SLASH_UNKNOWN_PENALTY
value: {{ .Values.node.slash.unknownPenalty | quote }}
Expand Down
1 change: 1 addition & 0 deletions spartan/aztec-node/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ node:
invalidBlockPenalty: ""
proposeInvalidAttestationsPenalty: ""
attestDescendantOfInvalidPenalty: ""
attestInvalidCheckpointProposalPenalty: ""
unknownPenalty: ""
# Slasher behavior configuration
gracePeriodL2Slots: ""
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 @@ -133,6 +133,8 @@ slasher: &slasher
SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY: 10e18
# Penalty for attesting to a descendant of an invalid block.
SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 10e18
# Penalty for attesting to an invalid checkpoint proposal.
SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY: 10e18
# Penalty for proposing two different block or checkpoint proposal for the same position.
SLASH_DUPLICATE_PROPOSAL_PENALTY: 0
# Penalty for signing attestations for different proposals at the same slot.
Expand Down Expand Up @@ -242,6 +244,7 @@ networks:
SLASH_DUPLICATE_PROPOSAL_PENALTY: 10e18
SLASH_DUPLICATE_ATTESTATION_PENALTY: 10e18
SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 10e18
SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY: 10e18
SLASH_UNKNOWN_PENALTY: 10e18
SLASH_INVALID_BLOCK_PENALTY: 10e18
SLASH_GRACE_PERIOD_L2_SLOTS: 0
Expand Down Expand Up @@ -287,6 +290,7 @@ networks:
SLASH_DUPLICATE_PROPOSAL_PENALTY: 10e18
SLASH_DUPLICATE_ATTESTATION_PENALTY: 10e18
SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 10e18
SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY: 10e18
SLASH_UNKNOWN_PENALTY: 10e18
SLASH_INVALID_BLOCK_PENALTY: 10e18
SLASH_GRACE_PERIOD_L2_SLOTS: 64
Expand Down Expand Up @@ -346,6 +350,7 @@ networks:
SLASH_DUPLICATE_PROPOSAL_PENALTY: 2000e18
SLASH_DUPLICATE_ATTESTATION_PENALTY: 2000e18
SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 2000e18
SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY: 2000e18
SLASH_UNKNOWN_PENALTY: 2000e18
SLASH_INVALID_BLOCK_PENALTY: 2000e18
SLASH_GRACE_PERIOD_L2_SLOTS: 1200
1 change: 1 addition & 0 deletions spartan/scripts/deploy_network.sh
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY = ${SLASH_PROPOSE_INVALID_ATTESTATION
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_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY = ${SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY:-null}
SLASH_UNKNOWN_PENALTY = ${SLASH_UNKNOWN_PENALTY:-null}
SLASH_INVALID_BLOCK_PENALTY = ${SLASH_INVALID_BLOCK_PENALTY:-null}
SLASH_OFFENSE_EXPIRATION_ROUNDS = ${SLASH_OFFENSE_EXPIRATION_ROUNDS:-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 @@ -206,6 +206,7 @@ locals {
"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.attestInvalidCheckpointProposalPenalty" = var.SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY
"validator.slash.unknownPenalty" = var.SLASH_UNKNOWN_PENALTY
"validator.slash.invalidBlockPenalty" = var.SLASH_INVALID_BLOCK_PENALTY
"validator.slash.offenseExpirationRounds" = var.SLASH_OFFENSE_EXPIRATION_ROUNDS
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 @@ -502,6 +502,12 @@ variable "SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY" {
nullable = true
}

variable "SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY" {
description = "The slash attest invalid checkpoint proposal penalty"
type = string
nullable = true
}

variable "SLASH_UNKNOWN_PENALTY" {
description = "The slash unknown penalty"
type = string
Expand Down
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 @@ -250,6 +250,7 @@ export type EnvVar =
| 'SLASH_OVERRIDE_PAYLOAD'
| 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY'
| 'SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY'
| 'SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY'
| 'SLASH_UNKNOWN_PENALTY'
| 'SLASH_GRACE_PERIOD_L2_SLOTS'
| 'SLASH_OFFENSE_EXPIRATION_ROUNDS'
Expand Down
4 changes: 4 additions & 0 deletions yarn-project/p2p/src/client/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
DuplicateAttestationInfo,
DuplicateProposalInfo,
P2PBlockReceivedCallback,
P2PCheckpointAttestationCallback,
P2PCheckpointReceivedCallback,
} from '../services/service.js';

Expand Down Expand Up @@ -109,6 +110,9 @@ export type P2P = P2PClient & {
*/
registerDuplicateAttestationCallback(callback: (info: DuplicateAttestationInfo) => void): void;

/** Registers a callback invoked when a valid checkpoint attestation is accepted into the pool. */
registerCheckpointAttestationCallback(callback: P2PCheckpointAttestationCallback): void;

/**
* Verifies the 'tx' and, if valid, adds it to local tx pool and forwards it to other peers.
* @param tx - The transaction.
Expand Down
4 changes: 4 additions & 0 deletions yarn-project/p2p/src/client/p2p_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,10 @@ export class P2PClient extends WithTracer implements P2P {
this.p2pService.registerDuplicateAttestationCallback(callback);
}

public registerCheckpointAttestationCallback(callback: (attestation: CheckpointAttestation) => void): void {
this.p2pService.registerCheckpointAttestationCallback(callback);
}

public async getPendingTxs(limit?: number, after?: TxHash): Promise<Tx[]> {
if (limit !== undefined && limit <= 0) {
throw new TypeError('limit must be greater than 0');
Expand Down
3 changes: 3 additions & 0 deletions yarn-project/p2p/src/services/dummy_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { GoodByeReason } from './reqresp/protocols/goodbye.js';
import { ReqRespStatus } from './reqresp/status.js';
import {
type P2PBlockReceivedCallback,
type P2PCheckpointAttestationCallback,
type P2PCheckpointReceivedCallback,
type P2PDuplicateAttestationCallback,
type P2PDuplicateProposalCallback,
Expand Down Expand Up @@ -103,6 +104,8 @@ export class DummyP2PService implements P2PService {
*/
public registerDuplicateAttestationCallback(_callback: P2PDuplicateAttestationCallback): void {}

public registerCheckpointAttestationCallback(_callback: P2PCheckpointAttestationCallback): void {}

/**
* Sends a request to a peer.
* @param _protocol - The protocol to send the request on.
Expand Down
22 changes: 22 additions & 0 deletions yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,7 @@ describe('LibP2PService', () => {
let mockEpochCache: MockProxy<EpochCacheInterface>;
let proposerSigner: Secp256k1Signer;
let duplicateAttestationCallback: jest.Mock;
let checkpointAttestationCallback: jest.Mock;

const targetSlot = SlotNumber(100);
const nextSlot = SlotNumber(101);
Expand Down Expand Up @@ -1113,6 +1114,8 @@ describe('LibP2PService', () => {

duplicateAttestationCallback = jest.fn();
service.registerDuplicateAttestationCallback(duplicateAttestationCallback);
checkpointAttestationCallback = jest.fn();
service.registerCheckpointAttestationCallback(checkpointAttestationCallback);
});

// Regression for A-1013: attestations sharing (slot, signer, archive) but differing on
Expand Down Expand Up @@ -1150,6 +1153,9 @@ describe('LibP2PService', () => {
slot: targetSlot,
attester: attesterSigner.address,
});
expect(checkpointAttestationCallback).toHaveBeenCalledTimes(2);
expect(checkpointAttestationCallback).toHaveBeenNthCalledWith(1, attestation1);
expect(checkpointAttestationCallback).toHaveBeenNthCalledWith(2, attestation2);
});

it('different signers are not equivocations and do not trigger slash callback', async () => {
Expand Down Expand Up @@ -1178,6 +1184,22 @@ describe('LibP2PService', () => {

// Two distinct signers are not an equivocation; the pool tracks per-(slot, signer).
expect(duplicateAttestationCallback).not.toHaveBeenCalled();
expect(checkpointAttestationCallback).toHaveBeenCalledTimes(2);
});

it('does not trigger accepted-attestation callback for exact duplicates', async () => {
const attesterSigner = Secp256k1Signer.random();
const attestation = makeCheckpointAttestation({
header: makeCheckpointHeader(1, { slotNumber: targetSlot }),
archive: Fr.random(),
attesterSigner,
proposerSigner,
});

await service.validateAndStoreCheckpointAttestation(mockPeerId, attestation);
await service.validateAndStoreCheckpointAttestation(mockPeerId, attestation);

expect(checkpointAttestationCallback).toHaveBeenCalledTimes(1);
});
});

Expand Down
9 changes: 9 additions & 0 deletions yarn-project/p2p/src/services/libp2p/libp2p_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
import { ReqResp } from '../reqresp/reqresp.js';
import type {
P2PBlockReceivedCallback,
P2PCheckpointAttestationCallback,
P2PCheckpointReceivedCallback,
P2PDuplicateAttestationCallback,
P2PService,
Expand Down Expand Up @@ -157,6 +158,9 @@ export class LibP2PService extends WithTracer implements P2PService {
/** Callback invoked when a duplicate attestation is detected (triggers slashing). */
private duplicateAttestationCallback?: P2PDuplicateAttestationCallback;

/** Callback invoked when a valid checkpoint attestation is accepted into the pool. */
private checkpointAttestationCallback?: P2PCheckpointAttestationCallback;

/**
* Callback for when a block is received from a peer.
* @param block - The block received from the peer.
Expand Down Expand Up @@ -764,6 +768,10 @@ export class LibP2PService extends WithTracer implements P2PService {
this.duplicateAttestationCallback = callback;
}

public registerCheckpointAttestationCallback(callback: P2PCheckpointAttestationCallback): void {
this.checkpointAttestationCallback = callback;
}

/**
* Subscribes to a topic.
* @param topic - The topic to subscribe to.
Expand Down Expand Up @@ -1224,6 +1232,7 @@ export class LibP2PService extends WithTracer implements P2PService {
}

// Attestation was added successfully - accept it so other nodes can also detect the equivocation
this.checkpointAttestationCallback?.(attestation);
return { result: TopicValidatorResult.Accept, obj: attestation };
}

Expand Down
4 changes: 4 additions & 0 deletions yarn-project/p2p/src/services/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export type DuplicateAttestationInfo = {
*/
export type P2PDuplicateAttestationCallback = (info: DuplicateAttestationInfo) => void;

export type P2PCheckpointAttestationCallback = (attestation: CheckpointAttestation) => void;

/**
* The interface for a P2P service implementation.
*/
Expand Down Expand Up @@ -137,6 +139,8 @@ export interface P2PService {
*/
registerDuplicateAttestationCallback(callback: P2PDuplicateAttestationCallback): void;

registerCheckpointAttestationCallback(callback: P2PCheckpointAttestationCallback): void;

getEnr(): ENR | undefined;

getPeers(includePending?: boolean): PeerInfo[];
Expand Down
7 changes: 7 additions & 0 deletions yarn-project/slasher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ List of all slashable offenses in the system:
**Target**: Proposer who broadcast the duplicate proposal.
**Time Unit**: Slot-based offense.

### ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL
**Description**: A committee member attested to a checkpoint proposal in a slot where this node detected a slashable invalid block proposal.
**Detection**: ValidatorClient marks slots with invalid block proposals detected via reexecution and slashes checkpoint attesters seen for that slot. If proposal equivocation is later detected for the slot, pending bad-attestation offenses are cleared.
**Target**: Committee members who attested in the invalid proposal slot.
**Time Unit**: Slot-based offense.

## Configuration

### L1 System Settings (L1ContractsConfig)
Expand Down Expand Up @@ -164,6 +170,7 @@ These settings are configured locally on each validator node:
- `slashDuplicateProposalPenalty`: Penalty for DUPLICATE_PROPOSAL
- `slashProposeInvalidAttestationsPenalty`: Penalty for PROPOSED_INSUFFICIENT_ATTESTATIONS and PROPOSED_INCORRECT_ATTESTATIONS
- `slashAttestDescendantOfInvalidPenalty`: Penalty for ATTESTED_DESCENDANT_OF_INVALID
- `slashAttestInvalidCheckpointProposalPenalty`: Penalty for ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL
- `slashUnknownPenalty`: Default penalty for unknown offense types
- `slashMaxPayloadSize`: Limits the number of **unique validators** (across all committees and epochs in a round) that receive non-zero votes. When this cap is hit, the lowest-severity validator-epoch pairs are zeroed out first, so the most severe slashes are always preserved. Note that multiple offenses for the same validator in the same epoch are summed and counted as a single validator entry against this limit.

Expand Down
9 changes: 9 additions & 0 deletions yarn-project/slasher/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export const DefaultSlasherConfig: SlasherConfig = {
slashInactivityPenalty: BigInt(slasherDefaultEnv.SLASH_INACTIVITY_PENALTY),
slashProposeInvalidAttestationsPenalty: BigInt(slasherDefaultEnv.SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY),
slashAttestDescendantOfInvalidPenalty: BigInt(slasherDefaultEnv.SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY),
slashAttestInvalidCheckpointProposalPenalty: BigInt(
slasherDefaultEnv.SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY,
),
slashUnknownPenalty: BigInt(slasherDefaultEnv.SLASH_UNKNOWN_PENALTY),
slashOffenseExpirationRounds: slasherDefaultEnv.SLASH_OFFENSE_EXPIRATION_ROUNDS,
slashMaxPayloadSize: slasherDefaultEnv.SLASH_MAX_PAYLOAD_SIZE,
Expand Down Expand Up @@ -127,6 +130,12 @@ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
'Penalty amount for slashing a validator that attested to a descendant of an invalid block (set to 0 to disable).',
...bigintConfigHelper(DefaultSlasherConfig.slashAttestDescendantOfInvalidPenalty),
},
slashAttestInvalidCheckpointProposalPenalty: {
env: 'SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY',
description:
'Penalty amount for slashing a validator that attested to an invalid checkpoint proposal (set to 0 to disable).',
...bigintConfigHelper(DefaultSlasherConfig.slashAttestInvalidCheckpointProposalPenalty),
},
slashUnknownPenalty: {
env: 'SLASH_UNKNOWN_PENALTY',
description: 'Penalty amount for slashing a validator for an unknown offense (set to 0 to disable).',
Expand Down
94 changes: 93 additions & 1 deletion yarn-project/slasher/src/slash_offenses_collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ import { openTmpStore } from '@aztec/kv-store/lmdb';
import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
import { type Offense, OffenseType } from '@aztec/stdlib/slashing';

import { jest } from '@jest/globals';
import { EventEmitter } from 'events';

import { DefaultSlasherConfig } from './config.js';
import { SlashOffensesCollector, type SlashOffensesCollectorSettings } from './slash_offenses_collector.js';
import { SlasherOffensesStore } from './stores/offenses_store.js';
import type { WantToSlashArgs } from './watcher.js';
import {
WANT_TO_CLEAR_SLASH_EVENT,
WANT_TO_SLASH_EVENT,
type WantToClearSlashArgs,
type WantToSlashArgs,
type Watcher,
} from './watcher.js';

describe('SlashOffensesCollector', () => {
let offensesCollector: SlashOffensesCollector;
Expand Down Expand Up @@ -196,4 +205,87 @@ describe('SlashOffensesCollector', () => {
epochOrSlot: 175n,
});
});

it('should handle want-to-clear-slash events', async () => {
const validator1 = EthAddress.random();
const validator2 = EthAddress.random();
const offenses: WantToSlashArgs[] = [
{
validator: validator1,
amount: 1000000000000000000n,
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 150n,
},
{
validator: validator2,
amount: 1000000000000000000n,
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 150n,
},
{
validator: validator1,
amount: 1000000000000000000n,
offenseType: OffenseType.DUPLICATE_PROPOSAL,
epochOrSlot: 150n,
},
];
const clearArgs: WantToClearSlashArgs[] = [
{
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 150n,
},
];

await offensesCollector.handleWantToSlash(offenses);
await offensesCollector.handleWantToClearSlash(clearArgs);

const pendingOffenses = await offensesStore.getOffenses();
expect(pendingOffenses).toHaveLength(1);
expect(pendingOffenses[0]).toMatchObject({
validator: validator1,
offenseType: OffenseType.DUPLICATE_PROPOSAL,
epochOrSlot: 150n,
});
});

it('should process queued slash and clear events in emission order', async () => {
const watcher = new EventEmitter() as unknown as Watcher;
watcher.updateConfig = jest.fn();
offensesCollector = new SlashOffensesCollector(config, settings, [watcher], offensesStore, logger);
await offensesCollector.start();

const validator1 = EthAddress.random();
const validator2 = EthAddress.random();

watcher.emit(WANT_TO_SLASH_EVENT, [
{
validator: validator1,
amount: 1000000000000000000n,
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 150n,
},
{
validator: validator2,
amount: 1000000000000000000n,
offenseType: OffenseType.DUPLICATE_PROPOSAL,
epochOrSlot: 150n,
},
] satisfies WantToSlashArgs[]);
watcher.emit(WANT_TO_CLEAR_SLASH_EVENT, [
{
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 150n,
},
] satisfies WantToClearSlashArgs[]);

await offensesCollector.stop();

const pendingOffenses = await offensesStore.getOffenses();
expect(pendingOffenses).toHaveLength(1);
expect(pendingOffenses[0]).toMatchObject({
validator: validator2,
offenseType: OffenseType.DUPLICATE_PROPOSAL,
epochOrSlot: 150n,
});
});
});
Loading
Loading