diff --git a/spartan/aztec-node/templates/_pod-template.yaml b/spartan/aztec-node/templates/_pod-template.yaml index 386163bbe885..67bfaec31a23 100644 --- a/spartan/aztec-node/templates/_pod-template.yaml +++ b/spartan/aztec-node/templates/_pod-template.yaml @@ -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 }} diff --git a/spartan/aztec-node/values.yaml b/spartan/aztec-node/values.yaml index ff52ba990a61..7107ca402f82 100644 --- a/spartan/aztec-node/values.yaml +++ b/spartan/aztec-node/values.yaml @@ -155,6 +155,7 @@ node: invalidBlockPenalty: "" proposeInvalidAttestationsPenalty: "" attestDescendantOfInvalidPenalty: "" + attestInvalidCheckpointProposalPenalty: "" unknownPenalty: "" # Slasher behavior configuration gracePeriodL2Slots: "" diff --git a/spartan/environments/network-defaults.yml b/spartan/environments/network-defaults.yml index 4580985d17e5..3bfe0cd37aaa 100644 --- a/spartan/environments/network-defaults.yml +++ b/spartan/environments/network-defaults.yml @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/spartan/scripts/deploy_network.sh b/spartan/scripts/deploy_network.sh index 6027cad2d272..edd3526eb4b8 100755 --- a/spartan/scripts/deploy_network.sh +++ b/spartan/scripts/deploy_network.sh @@ -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} diff --git a/spartan/terraform/deploy-aztec-infra/main.tf b/spartan/terraform/deploy-aztec-infra/main.tf index 6b4d33e803fb..13ea3870380f 100644 --- a/spartan/terraform/deploy-aztec-infra/main.tf +++ b/spartan/terraform/deploy-aztec-infra/main.tf @@ -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 diff --git a/spartan/terraform/deploy-aztec-infra/variables.tf b/spartan/terraform/deploy-aztec-infra/variables.tf index 834b4d833217..538f37fd0b23 100644 --- a/spartan/terraform/deploy-aztec-infra/variables.tf +++ b/spartan/terraform/deploy-aztec-infra/variables.tf @@ -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 diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index f6dbfdbb9abb..8cf507f49279 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -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' diff --git a/yarn-project/p2p/src/client/interface.ts b/yarn-project/p2p/src/client/interface.ts index 511d0c99b1a1..b7bf5fb19d0c 100644 --- a/yarn-project/p2p/src/client/interface.ts +++ b/yarn-project/p2p/src/client/interface.ts @@ -18,6 +18,7 @@ import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2PBlockReceivedCallback, + P2PCheckpointAttestationCallback, P2PCheckpointReceivedCallback, } from '../services/service.js'; @@ -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. diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index be44da36cab8..a91755a81b00 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -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 { if (limit !== undefined && limit <= 0) { throw new TypeError('limit must be greater than 0'); diff --git a/yarn-project/p2p/src/services/dummy_service.ts b/yarn-project/p2p/src/services/dummy_service.ts index 0e7beea22441..d89cfcc59635 100644 --- a/yarn-project/p2p/src/services/dummy_service.ts +++ b/yarn-project/p2p/src/services/dummy_service.ts @@ -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, @@ -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. diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts index b397a0a4928a..7947da64ad32 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts @@ -1084,6 +1084,7 @@ describe('LibP2PService', () => { let mockEpochCache: MockProxy; let proposerSigner: Secp256k1Signer; let duplicateAttestationCallback: jest.Mock; + let checkpointAttestationCallback: jest.Mock; const targetSlot = SlotNumber(100); const nextSlot = SlotNumber(101); @@ -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 @@ -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 () => { @@ -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); }); }); diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 327e3e4411ef..9c4704972963 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -112,6 +112,7 @@ import { import { ReqResp } from '../reqresp/reqresp.js'; import type { P2PBlockReceivedCallback, + P2PCheckpointAttestationCallback, P2PCheckpointReceivedCallback, P2PDuplicateAttestationCallback, P2PService, @@ -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. @@ -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. @@ -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 }; } diff --git a/yarn-project/p2p/src/services/service.ts b/yarn-project/p2p/src/services/service.ts index f7f5e09267d2..e3b7590e83b1 100644 --- a/yarn-project/p2p/src/services/service.ts +++ b/yarn-project/p2p/src/services/service.ts @@ -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. */ @@ -137,6 +139,8 @@ export interface P2PService { */ registerDuplicateAttestationCallback(callback: P2PDuplicateAttestationCallback): void; + registerCheckpointAttestationCallback(callback: P2PCheckpointAttestationCallback): void; + getEnr(): ENR | undefined; getPeers(includePending?: boolean): PeerInfo[]; diff --git a/yarn-project/slasher/README.md b/yarn-project/slasher/README.md index 4877a3b2860b..fd8aa439b041 100644 --- a/yarn-project/slasher/README.md +++ b/yarn-project/slasher/README.md @@ -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) @@ -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. diff --git a/yarn-project/slasher/src/config.ts b/yarn-project/slasher/src/config.ts index 9a763d83402d..26102d3bb805 100644 --- a/yarn-project/slasher/src/config.ts +++ b/yarn-project/slasher/src/config.ts @@ -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, @@ -127,6 +130,12 @@ export const slasherConfigMappings: ConfigMappingsType = { '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).', diff --git a/yarn-project/slasher/src/slash_offenses_collector.test.ts b/yarn-project/slasher/src/slash_offenses_collector.test.ts index b86774bdb567..c599ac0866a8 100644 --- a/yarn-project/slasher/src/slash_offenses_collector.test.ts +++ b/yarn-project/slasher/src/slash_offenses_collector.test.ts @@ -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; @@ -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, + }); + }); }); diff --git a/yarn-project/slasher/src/slash_offenses_collector.ts b/yarn-project/slasher/src/slash_offenses_collector.ts index 371f5830d80e..f92e5dd790ad 100644 --- a/yarn-project/slasher/src/slash_offenses_collector.ts +++ b/yarn-project/slasher/src/slash_offenses_collector.ts @@ -1,12 +1,19 @@ import type { SlotNumber } from '@aztec/foundation/branded-types'; import { createLogger } from '@aztec/foundation/log'; +import { SerialQueue } from '@aztec/foundation/queue'; import type { Prettify } from '@aztec/foundation/types'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import type { SlasherConfig } from '@aztec/stdlib/interfaces/server'; import { type Offense, getSlotForOffense } from '@aztec/stdlib/slashing'; import type { SlasherOffensesStore } from './stores/offenses_store.js'; -import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher } from './watcher.js'; +import { + WANT_TO_CLEAR_SLASH_EVENT, + WANT_TO_SLASH_EVENT, + type WantToClearSlashArgs, + type WantToSlashArgs, + type Watcher, +} from './watcher.js'; export type SlashOffensesCollectorConfig = Prettify>; export type SlashOffensesCollectorSettings = Prettify< @@ -24,6 +31,7 @@ export type SlashOffensesCollectorSettings = Prettify< */ export class SlashOffensesCollector { private readonly unwatchCallbacks: (() => void)[] = []; + private readonly storeMutationQueue = new SerialQueue(); constructor( private readonly config: SlashOffensesCollectorConfig, @@ -35,28 +43,35 @@ export class SlashOffensesCollector { public start() { this.log.debug('Starting SlashOffensesCollector...'); + this.storeMutationQueue.start(); - // Subscribe to watchers WANT_TO_SLASH_EVENT + // Subscribe to watcher slashing events. for (const watcher of this.watchers) { const wantToSlashCallback = (args: WantToSlashArgs[]) => - void this.handleWantToSlash(args).catch(err => this.log.error('Error handling wantToSlash', err)); + this.enqueueStoreMutation('wantToSlash', () => this.handleWantToSlash(args)); watcher.on(WANT_TO_SLASH_EVENT, wantToSlashCallback); this.unwatchCallbacks.push(() => watcher.removeListener(WANT_TO_SLASH_EVENT, wantToSlashCallback)); + + const wantToClearSlashCallback = (args: WantToClearSlashArgs[]) => + this.enqueueStoreMutation('wantToClearSlash', () => this.handleWantToClearSlash(args)); + watcher.on(WANT_TO_CLEAR_SLASH_EVENT, wantToClearSlashCallback); + this.unwatchCallbacks.push(() => watcher.removeListener(WANT_TO_CLEAR_SLASH_EVENT, wantToClearSlashCallback)); } this.log.info('Started SlashOffensesCollector'); return Promise.resolve(); } - public stop() { + public async stop() { this.log.debug('Stopping SlashOffensesCollector...'); for (const unwatchCallback of this.unwatchCallbacks) { unwatchCallback(); } + await this.storeMutationQueue.end(); + this.log.info('SlashOffensesCollector stopped'); - return Promise.resolve(); } /** @@ -78,20 +93,32 @@ export class SlashOffensesCollector { continue; } - if (await this.offensesStore.hasOffense(offense)) { + const added = await this.offensesStore.addOffense(offense); + if (added) { + if (this.settings.slashingAmounts) { + const minSlash = this.settings.slashingAmounts[0]; + if (arg.amount < minSlash) { + this.log.warn(`Offense amount ${arg.amount} is below minimum slashing amount ${minSlash}`); + } + } + + this.log.info(`Adding pending offense for validator ${arg.validator}`, offense); + } else { this.log.debug('Skipping repeated offense', offense); - continue; } + } + } - if (this.settings.slashingAmounts) { - const minSlash = this.settings.slashingAmounts[0]; - if (arg.amount < minSlash) { - this.log.warn(`Offense amount ${arg.amount} is below minimum slashing amount ${minSlash}`); - } + public async handleWantToClearSlash(args: WantToClearSlashArgs[]) { + for (const arg of args) { + const cleared = await this.offensesStore.clearOffenses(arg); + if (cleared > 0) { + this.log.info(`Cleared ${cleared} pending offenses`, { + offenseType: arg.offenseType, + epochOrSlot: arg.epochOrSlot, + validators: arg.validators?.map(validator => validator.toString()), + }); } - - this.log.info(`Adding pending offense for validator ${arg.validator}`, offense); - await this.offensesStore.addOffense(offense); } } @@ -111,4 +138,8 @@ export class SlashOffensesCollector { const offenseSlot = getSlotForOffense(offense, this.settings); return offenseSlot < this.settings.rollupRegisteredAtL2Slot + this.config.slashGracePeriodL2Slots; } + + private enqueueStoreMutation(label: string, callback: () => Promise) { + void this.storeMutationQueue.put(callback).catch(err => this.log.error(`Error handling ${label}`, err)); + } } diff --git a/yarn-project/slasher/src/stores/offenses_store.test.ts b/yarn-project/slasher/src/stores/offenses_store.test.ts index 5353780faf86..6215e541cbca 100644 --- a/yarn-project/slasher/src/stores/offenses_store.test.ts +++ b/yarn-project/slasher/src/stores/offenses_store.test.ts @@ -51,7 +51,7 @@ describe('SlasherOffensesStore', () => { it('should add and retrieve a single offense', async () => { const offense = createOffense(); - await store.addOffense(offense); + await expect(store.addOffense(offense)).resolves.toBe(true); const pendingOffenses = await store.getOffenses(); expect(pendingOffenses).toHaveLength(1); @@ -254,12 +254,63 @@ describe('SlasherOffensesStore', () => { }); }); + describe('clearOffenses', () => { + it('clears matching offenses and keeps unrelated offenses', async () => { + const validator1 = EthAddress.random(); + const validator2 = EthAddress.random(); + const matching1 = createOffense(validator1, 1000n, OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, 150n); + const matching2 = createOffense(validator2, 1000n, OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, 150n); + const otherSlot = createOffense(validator1, 1000n, OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, 151n); + const otherType = createOffense(validator1, 1000n, OffenseType.DUPLICATE_PROPOSAL, 150n); + + await store.addOffense(matching1); + await store.addOffense(matching2); + await store.addOffense(otherSlot); + await store.addOffense(otherType); + + const cleared = await store.clearOffenses({ + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: 150n, + }); + + expect(cleared).toBe(2); + expect(await store.hasOffense(matching1)).toBe(false); + expect(await store.hasOffense(matching2)).toBe(false); + expect(await store.hasOffense(otherSlot)).toBe(true); + expect(await store.hasOffense(otherType)).toBe(true); + const roundOffenses = await store.getOffensesForRound(1n); + expect(roundOffenses).toHaveLength(2); + expect(roundOffenses).toContainEqual(otherSlot); + expect(roundOffenses).toContainEqual(otherType); + }); + + it('can clear only selected validators', async () => { + const validator1 = EthAddress.random(); + const validator2 = EthAddress.random(); + const matching1 = createOffense(validator1, 1000n, OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, 150n); + const matching2 = createOffense(validator2, 1000n, OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, 150n); + + await store.addOffense(matching1); + await store.addOffense(matching2); + + const cleared = await store.clearOffenses({ + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: 150n, + validators: [validator1], + }); + + expect(cleared).toBe(1); + expect(await store.hasOffense(matching1)).toBe(false); + expect(await store.hasOffense(matching2)).toBe(true); + }); + }); + describe('edge cases', () => { it('should handle duplicate offense additions', async () => { const offense = createOffense(); - await store.addOffense(offense); - await store.addOffense(offense); // Duplicate + await expect(store.addOffense(offense)).resolves.toBe(true); + await expect(store.addOffense(offense)).resolves.toBe(false); const pendingOffenses = await store.getOffenses(); expect(pendingOffenses).toHaveLength(1); diff --git a/yarn-project/slasher/src/stores/offenses_store.ts b/yarn-project/slasher/src/stores/offenses_store.ts index caa5ac4b171d..46a10d7430b8 100644 --- a/yarn-project/slasher/src/stores/offenses_store.ts +++ b/yarn-project/slasher/src/stores/offenses_store.ts @@ -10,6 +10,10 @@ import { export const SCHEMA_VERSION = 1; +type ClearOffensesFilter = Pick & { + validators?: Offense['validator'][]; +}; + export class SlasherOffensesStore { /** Map from offense key to offense data */ private offenses: AztecAsyncMap; @@ -59,15 +63,63 @@ export class SlasherOffensesStore { return (await this.offenses.getAsync(key)) !== undefined; } - /** Adds a new offense */ - public async addOffense(offense: Offense): Promise { + /** Adds a new offense. Returns false if the offense is already pending. */ + public async addOffense(offense: Offense): Promise { const key = this.getOffenseKey(offense); const round = getRoundForOffense(offense, this.settings); - await this.kvStore.transactionAsync(async () => { + const added = await this.kvStore.transactionAsync(async () => { + if ((await this.offenses.getAsync(key)) !== undefined) { + return false; + } + await this.offenses.set(key, serializeOffense(offense)); await this.roundsOffenses.set(this.getRoundKey(round), key); + return true; + }); + + if (added) { + this.log.trace(`Adding pending offense ${key} for round ${round}`); + } + + return added; + } + + /** Removes pending offenses matching the given offense type, epoch/slot, and optional validators. */ + public async clearOffenses(filter: ClearOffensesFilter): Promise { + return await this.kvStore.transactionAsync(async () => { + const offensesToClear = new Map(); + + if (filter.validators && filter.validators.length > 0) { + for (const validator of filter.validators) { + const identifier = { validator, offenseType: filter.offenseType, epochOrSlot: filter.epochOrSlot }; + const key = this.getOffenseKey(identifier); + const buffer = await this.offenses.getAsync(key); + if (buffer) { + offensesToClear.set(key, deserializeOffense(buffer)); + } + } + } else { + for await (const [key, buffer] of this.offenses.entriesAsync()) { + const offense = deserializeOffense(buffer); + if (offense.offenseType === filter.offenseType && offense.epochOrSlot === filter.epochOrSlot) { + offensesToClear.set(key, offense); + } + } + } + + if (offensesToClear.size === 0) { + return 0; + } + + for (const [key, offense] of offensesToClear) { + const round = getRoundForOffense(offense, this.settings); + await this.offenses.delete(key); + await this.roundsOffenses.deleteValue(this.getRoundKey(round), key); + this.log.trace(`Cleared pending offense ${key} for round ${round}`); + } + + return offensesToClear.size; }); - this.log.trace(`Adding pending offense ${key} for round ${round}`); } /** Prunes all offenses expired from the store */ @@ -82,22 +134,21 @@ export class SlasherOffensesStore { return 0; // Not enough rounds have passed to expire anything } - // Collect expired offenses and rounds - const expiredRoundKeys = new Set(); - const expiredOffenseKeys = new Set(); - for await (const [roundKey, offenseKey] of this.roundsOffenses.entriesAsync({ - end: this.getRoundKey(expiredBefore), - })) { - expiredOffenseKeys.add(offenseKey); - expiredRoundKeys.add(roundKey); - } + return await this.kvStore.transactionAsync(async () => { + // Collect expired offenses and rounds + const expiredRoundKeys = new Set(); + const expiredOffenseKeys = new Set(); + for await (const [roundKey, offenseKey] of this.roundsOffenses.entriesAsync({ + end: this.getRoundKey(expiredBefore), + })) { + expiredOffenseKeys.add(offenseKey); + expiredRoundKeys.add(roundKey); + } - if (expiredOffenseKeys.size === 0 && expiredRoundKeys.size === 0) { - return 0; // Nothing to clean up - } + if (expiredOffenseKeys.size === 0 && expiredRoundKeys.size === 0) { + return 0; // Nothing to clean up + } - // Remove expired stuff in a transaction - await this.kvStore.transactionAsync(async () => { for (const key of expiredOffenseKeys) { this.log.trace(`Deleting offense ${key}`); await this.offenses.delete(key); @@ -106,9 +157,9 @@ export class SlasherOffensesStore { this.log.trace(`Deleting round info for ${roundKey}`); await this.roundsOffenses.delete(roundKey); } - }); - return expiredOffenseKeys.size; + return expiredOffenseKeys.size; + }); } /** Generate a unique key for an offense */ diff --git a/yarn-project/slasher/src/watcher.ts b/yarn-project/slasher/src/watcher.ts index 3f5371d18f5d..e8c84f0bce9b 100644 --- a/yarn-project/slasher/src/watcher.ts +++ b/yarn-project/slasher/src/watcher.ts @@ -5,6 +5,7 @@ import { OffenseType } from '@aztec/stdlib/slashing'; import type { SlasherConfig } from './config.js'; export const WANT_TO_SLASH_EVENT = 'want-to-slash' as const; +export const WANT_TO_CLEAR_SLASH_EVENT = 'want-to-clear-slash' as const; export interface WantToSlashArgs { validator: EthAddress; @@ -13,9 +14,16 @@ export interface WantToSlashArgs { epochOrSlot: bigint; // Epoch number for epoch-based offenses, slot number for slot-based } +export interface WantToClearSlashArgs { + offenseType: OffenseType; + epochOrSlot: bigint; // Epoch number for epoch-based offenses, slot number for slot-based + validators?: EthAddress[]; +} + // Event map for specific, known events of a watcher export interface WatcherEventMap { [WANT_TO_SLASH_EVENT]: (args: WantToSlashArgs[]) => void; + [WANT_TO_CLEAR_SLASH_EVENT]: (args: WantToClearSlashArgs[]) => void; } export type WatcherEmitter = TypedEventEmitter; diff --git a/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts index 8b42f9ba73dd..c5d8382380b3 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts @@ -115,6 +115,7 @@ class MockAztecNodeAdmin implements AztecNodeAdmin { slashBroadcastedInvalidBlockPenalty: 1n, slashDuplicateProposalPenalty: 1n, slashDuplicateAttestationPenalty: 1n, + slashAttestInvalidCheckpointProposalPenalty: 1000n, secondsBeforeInvalidatingBlockAsCommitteeMember: 0, secondsBeforeInvalidatingBlockAsNonCommitteeMember: 0, slashProposeInvalidAttestationsPenalty: 1000n, diff --git a/yarn-project/stdlib/src/interfaces/slasher.ts b/yarn-project/stdlib/src/interfaces/slasher.ts index 4a682992c3e7..9e71e16e0f16 100644 --- a/yarn-project/stdlib/src/interfaces/slasher.ts +++ b/yarn-project/stdlib/src/interfaces/slasher.ts @@ -18,6 +18,7 @@ export interface SlasherConfig { slashDuplicateAttestationPenalty: bigint; slashProposeInvalidAttestationsPenalty: bigint; slashAttestDescendantOfInvalidPenalty: bigint; + slashAttestInvalidCheckpointProposalPenalty: bigint; slashUnknownPenalty: bigint; slashOffenseExpirationRounds: number; // Number of rounds after which pending offenses expire slashMaxPayloadSize: number; // Maximum number of offenses to include in a single slash payload @@ -39,6 +40,7 @@ export const SlasherConfigSchema = zodFor()( slashDuplicateProposalPenalty: schemas.BigInt, slashDuplicateAttestationPenalty: schemas.BigInt, slashAttestDescendantOfInvalidPenalty: schemas.BigInt, + slashAttestInvalidCheckpointProposalPenalty: schemas.BigInt, slashUnknownPenalty: schemas.BigInt, slashOffenseExpirationRounds: z.number(), slashMaxPayloadSize: z.number(), diff --git a/yarn-project/stdlib/src/interfaces/validator.ts b/yarn-project/stdlib/src/interfaces/validator.ts index 71e307913785..1a8f9fddd19d 100644 --- a/yarn-project/stdlib/src/interfaces/validator.ts +++ b/yarn-project/stdlib/src/interfaces/validator.ts @@ -83,7 +83,10 @@ export type ValidatorClientFullConfig = ValidatorClientConfig & Pick & Pick< SlasherConfig, - 'slashBroadcastedInvalidBlockPenalty' | 'slashDuplicateProposalPenalty' | 'slashDuplicateAttestationPenalty' + | 'slashBroadcastedInvalidBlockPenalty' + | 'slashDuplicateProposalPenalty' + | 'slashDuplicateAttestationPenalty' + | 'slashAttestInvalidCheckpointProposalPenalty' > & { /** * Whether transactions are disabled for this node @@ -119,6 +122,7 @@ export const ValidatorClientFullConfigSchema = zodFor { expect(slot).toEqual(SlotNumber(25)); }); + it('returns slot directly for attesting to invalid checkpoint proposal', () => { + const offense = { + epochOrSlot: 25n, + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + }; + const slot = getSlotForOffense(offense, constants); + expect(slot).toEqual(SlotNumber(25)); + }); + it('returns first slot of epoch for epoch-based offenses', () => { const offense = { epochOrSlot: 5n, @@ -183,4 +193,23 @@ describe('SlashingHelpers', () => { expect(round).toEqual(1n); // slot 8 / roundSize 8 = round 1 }); }); + + describe('getPenaltyForOffense', () => { + it('returns the configured penalty for attesting to invalid checkpoint proposal', () => { + const penalty = getPenaltyForOffense(OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, { + slashAttestDescendantOfInvalidPenalty: 1n, + slashBroadcastedInvalidBlockPenalty: 2n, + slashDuplicateProposalPenalty: 3n, + slashDuplicateAttestationPenalty: 4n, + slashAttestInvalidCheckpointProposalPenalty: 5n, + slashPrunePenalty: 6n, + slashDataWithholdingPenalty: 7n, + slashUnknownPenalty: 8n, + slashInactivityPenalty: 9n, + slashProposeInvalidAttestationsPenalty: 10n, + }); + + expect(penalty).toBe(5n); + }); + }); }); diff --git a/yarn-project/stdlib/src/slashing/helpers.ts b/yarn-project/stdlib/src/slashing/helpers.ts index ff6be733acad..21ca279597f1 100644 --- a/yarn-project/stdlib/src/slashing/helpers.ts +++ b/yarn-project/stdlib/src/slashing/helpers.ts @@ -52,6 +52,7 @@ export function getPenaltyForOffense( | 'slashBroadcastedInvalidBlockPenalty' | 'slashDuplicateProposalPenalty' | 'slashDuplicateAttestationPenalty' + | 'slashAttestInvalidCheckpointProposalPenalty' | 'slashPrunePenalty' | 'slashDataWithholdingPenalty' | 'slashUnknownPenalty' @@ -77,6 +78,8 @@ export function getPenaltyForOffense( return config.slashDuplicateProposalPenalty; case OffenseType.DUPLICATE_ATTESTATION: return config.slashDuplicateAttestationPenalty; + case OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL: + return config.slashAttestInvalidCheckpointProposalPenalty; case OffenseType.UNKNOWN: return config.slashUnknownPenalty; default: { @@ -93,6 +96,7 @@ export function getTimeUnitForOffense(offense: OffenseType): 'epoch' | 'slot' { case OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL: case OffenseType.DUPLICATE_PROPOSAL: case OffenseType.DUPLICATE_ATTESTATION: + case OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL: case OffenseType.PROPOSED_INCORRECT_ATTESTATIONS: case OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS: return 'slot'; diff --git a/yarn-project/stdlib/src/slashing/serialization.test.ts b/yarn-project/stdlib/src/slashing/serialization.test.ts index 60c49a72a7e4..9c84b476a351 100644 --- a/yarn-project/stdlib/src/slashing/serialization.test.ts +++ b/yarn-project/stdlib/src/slashing/serialization.test.ts @@ -169,6 +169,7 @@ describe('slashing/serialization', () => { OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, OffenseType.PROPOSED_INCORRECT_ATTESTATIONS, OffenseType.ATTESTED_DESCENDANT_OF_INVALID, + OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL, ]; diff --git a/yarn-project/stdlib/src/slashing/types.ts b/yarn-project/stdlib/src/slashing/types.ts index cd3e7bab22a0..6a72b45c061b 100644 --- a/yarn-project/stdlib/src/slashing/types.ts +++ b/yarn-project/stdlib/src/slashing/types.ts @@ -24,6 +24,8 @@ export enum OffenseType { DUPLICATE_PROPOSAL = 8, /** A validator signed attestations for different proposals at the same slot (equivocation) */ DUPLICATE_ATTESTATION = 9, + /** A committee member attested to a checkpoint proposal in a slot with an invalid block proposal */ + ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL = 10, } export function getOffenseTypeName(offense: OffenseType) { @@ -48,6 +50,8 @@ export function getOffenseTypeName(offense: OffenseType) { return 'duplicate_proposal'; case OffenseType.DUPLICATE_ATTESTATION: return 'duplicate_attestation'; + case OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL: + return 'attested_to_invalid_checkpoint_proposal'; default: throw new Error(`Unknown offense type: ${offense}`); } @@ -66,6 +70,7 @@ export const OffenseToBigInt: Record = { [OffenseType.ATTESTED_DESCENDANT_OF_INVALID]: 7n, [OffenseType.DUPLICATE_PROPOSAL]: 8n, [OffenseType.DUPLICATE_ATTESTATION]: 9n, + [OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL]: 10n, }; export function bigIntToOffense(offense: bigint): OffenseType { @@ -90,6 +95,8 @@ export function bigIntToOffense(offense: bigint): OffenseType { return OffenseType.DUPLICATE_PROPOSAL; case 9n: return OffenseType.DUPLICATE_ATTESTATION; + case 10n: + return OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL; default: throw new Error(`Unknown offense: ${offense}`); } diff --git a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts index 3990a17d12d3..25bc4085dc75 100644 --- a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts +++ b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts @@ -4,6 +4,7 @@ import type { ENR, P2P, P2PBlockReceivedCallback, + P2PCheckpointAttestationCallback, P2PCheckpointReceivedCallback, P2PConfig, P2PDuplicateAttestationCallback, @@ -228,6 +229,10 @@ export class DummyP2P implements P2P { throw new Error('DummyP2P does not implement "registerDuplicateAttestationCallback"'); } + public registerCheckpointAttestationCallback(_callback: P2PCheckpointAttestationCallback): void { + throw new Error('DummyP2P does not implement "registerCheckpointAttestationCallback"'); + } + public hasBlockProposalsForSlot(_slot: SlotNumber): Promise { throw new Error('DummyP2P does not implement "hasBlockProposalsForSlot"'); } diff --git a/yarn-project/validator-client/src/validator.ha.integration.test.ts b/yarn-project/validator-client/src/validator.ha.integration.test.ts index e3b523d13493..673d689de741 100644 --- a/yarn-project/validator-client/src/validator.ha.integration.test.ts +++ b/yarn-project/validator-client/src/validator.ha.integration.test.ts @@ -135,7 +135,10 @@ describe('ValidatorClient HA Integration', () => { const baseConfig: ValidatorClientConfig & Pick< SlasherConfig, - 'slashBroadcastedInvalidBlockPenalty' | 'slashDuplicateProposalPenalty' | 'slashDuplicateAttestationPenalty' + | 'slashBroadcastedInvalidBlockPenalty' + | 'slashDuplicateProposalPenalty' + | 'slashDuplicateAttestationPenalty' + | 'slashAttestInvalidCheckpointProposalPenalty' > = { validatorPrivateKeys: new SecretValue(validatorPrivateKeys), attestationPollingIntervalMs: 1000, @@ -146,6 +149,7 @@ describe('ValidatorClient HA Integration', () => { l1ChainId: TEST_COORDINATION_SIGNATURE_CONTEXT.chainId, slashDuplicateProposalPenalty: 1n, slashDuplicateAttestationPenalty: 1n, + slashAttestInvalidCheckpointProposalPenalty: 1n, haSigningEnabled: true, nodeId: 'ha-node-1', // temporary pollingIntervalMs: 100, @@ -188,7 +192,10 @@ describe('ValidatorClient HA Integration', () => { config: ValidatorClientConfig & Pick< SlasherConfig, - 'slashBroadcastedInvalidBlockPenalty' | 'slashDuplicateProposalPenalty' | 'slashDuplicateAttestationPenalty' + | 'slashBroadcastedInvalidBlockPenalty' + | 'slashDuplicateProposalPenalty' + | 'slashDuplicateAttestationPenalty' + | 'slashAttestInvalidCheckpointProposalPenalty' >, ): Promise { // Track pool for cleanup diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index 249d6e026043..b50b2b331ba9 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -179,6 +179,7 @@ describe('ValidatorClient Integration', () => { slashBroadcastedInvalidBlockPenalty: 10n, slashDuplicateProposalPenalty: 10n, slashDuplicateAttestationPenalty: 10n, + slashAttestInvalidCheckpointProposalPenalty: 10n, haSigningEnabled: false, skipCheckpointProposalValidation: false, skipPushProposedBlocksToArchiver: false, diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index 0e891cba35ef..eba270068b10 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -27,7 +27,7 @@ import { type TxProvider, createSecp256k1PeerId, } from '@aztec/p2p'; -import { OffenseType, WANT_TO_SLASH_EVENT } from '@aztec/slasher'; +import { OffenseType, WANT_TO_CLEAR_SLASH_EVENT, WANT_TO_SLASH_EVENT } from '@aztec/slasher'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type BlockData, BlockHash, L2Block, type L2BlockSink, type L2BlockSource } from '@aztec/stdlib/block'; import { type getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; @@ -88,7 +88,10 @@ describe('ValidatorClient', () => { let config: ValidatorClientConfig & Pick< SlasherConfig, - 'slashBroadcastedInvalidBlockPenalty' | 'slashDuplicateProposalPenalty' | 'slashDuplicateAttestationPenalty' + | 'slashBroadcastedInvalidBlockPenalty' + | 'slashDuplicateProposalPenalty' + | 'slashDuplicateAttestationPenalty' + | 'slashAttestInvalidCheckpointProposalPenalty' > & { disableTransactions: boolean; }; @@ -181,6 +184,7 @@ describe('ValidatorClient', () => { slashBroadcastedInvalidBlockPenalty: 1n, slashDuplicateProposalPenalty: 1n, slashDuplicateAttestationPenalty: 1n, + slashAttestInvalidCheckpointProposalPenalty: 1n, disableTransactions: false, haSigningEnabled: false, l1ChainId: TEST_COORDINATION_SIGNATURE_CONTEXT.chainId, @@ -716,6 +720,107 @@ describe('ValidatorClient', () => { expect(emitSpy).not.toHaveBeenCalled(); }); + it('slashes checkpoint attestations received after an invalid proposal slot is marked only once', async () => { + await validatorClient.registerHandlers(); + const attestationCallback = p2pClient.registerCheckpointAttestationCallback.mock.calls[0][0]; + const emitSpy = jest.spyOn(validatorClient, 'emit'); + blockBuildResult.block.archive.root = Fr.random(); + + await validatorClient.validateBlockProposal(proposal, sender); + + const attesterSigner = Secp256k1Signer.random(); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: proposal.slotNumber }), + attesterSigner, + }); + attestationCallback(attestation); + attestationCallback(attestation); + + const badAttestationEvents = emitSpy.mock.calls.filter( + ([event, args]) => + event === WANT_TO_SLASH_EVENT && + Array.isArray(args) && + args[0]?.offenseType === OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + ); + expect(badAttestationEvents).toHaveLength(1); + expect(badAttestationEvents[0][1]).toEqual([ + { + validator: attesterSigner.address, + amount: config.slashAttestInvalidCheckpointProposalPenalty, + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(proposal.slotNumber), + }, + ]); + }); + + it('clears and suppresses bad attestation offenses when proposal equivocation is detected', async () => { + await validatorClient.registerHandlers(); + const attestationCallback = p2pClient.registerCheckpointAttestationCallback.mock.calls[0][0]; + const duplicateProposalCallback = p2pClient.registerDuplicateProposalCallback.mock.calls[0][0]; + const emitSpy = jest.spyOn(validatorClient, 'emit'); + blockBuildResult.block.archive.root = Fr.random(); + + await validatorClient.validateBlockProposal(proposal, sender); + duplicateProposalCallback({ + slot: proposal.slotNumber, + proposer: proposal.getSender()!, + type: 'block', + }); + + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: proposal.slotNumber }), + attesterSigner: Secp256k1Signer.random(), + }); + attestationCallback(attestation); + + expect(emitSpy).toHaveBeenCalledWith(WANT_TO_CLEAR_SLASH_EVENT, [ + { + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(proposal.slotNumber), + }, + ]); + expect( + emitSpy.mock.calls.some( + ([event, args]) => + event === WANT_TO_SLASH_EVENT && + Array.isArray(args) && + args[0]?.offenseType === OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + ), + ).toBe(false); + }); + + it('reexecutes for bad attestation slashing when invalid block proposer slashing is disabled', async () => { + validatorClient.updateConfig({ slashBroadcastedInvalidBlockPenalty: 0n }); + epochCache.filterInCommittee.mockResolvedValue([]); + blockBuildResult.block.archive.root = Fr.random(); + + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + + expect(isValid).toBe(false); + expect(checkpointsBuilder.openCheckpoint).toHaveBeenCalled(); + }); + + it('does not emit bad attestation offenses when the bad attestation penalty is disabled', async () => { + await validatorClient.registerHandlers(); + const attestationCallback = p2pClient.registerCheckpointAttestationCallback.mock.calls[0][0]; + validatorClient.updateConfig({ + slashBroadcastedInvalidBlockPenalty: 0n, + slashAttestInvalidCheckpointProposalPenalty: 0n, + }); + const emitSpy = jest.spyOn(validatorClient, 'emit'); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: proposal.slotNumber }), + attesterSigner: Secp256k1Signer.random(), + }); + blockBuildResult.block.archive.root = Fr.random(); + + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + attestationCallback(attestation); + + expect(isValid).toBe(false); + expect(emitSpy).not.toHaveBeenCalled(); + }); + it('should request txs for validating pinning the sender', async () => { const isValid = await validatorClient.validateBlockProposal(proposal, sender); expect(isValid).toBe(true); diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index d92a51717233..d7bc557d0b6b 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -12,7 +12,13 @@ import { DateProvider } from '@aztec/foundation/timer'; import type { KeystoreManager } from '@aztec/node-keystore'; import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p'; import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p'; -import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher'; +import { + OffenseType, + WANT_TO_CLEAR_SLASH_EVENT, + WANT_TO_SLASH_EVENT, + type Watcher, + type WatcherEmitter, +} from '@aztec/slasher'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; @@ -58,6 +64,8 @@ import { type BlockProposalValidationFailureReason, ProposalHandler } from './pr // We maintain a set of proposers who have proposed invalid blocks. // Just cap the set to avoid unbounded growth. const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000; +const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000; +const MAX_TRACKED_BAD_ATTESTATIONS = 10_000; // What errors from the block proposal handler result in slashing const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [ @@ -88,6 +96,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) private lastAttestedEpochByAttester: Map = new Map(); private proposersOfInvalidBlocks: Set = new Set(); + private slotsWithInvalidBlockProposals: Set = new Set(); + private slotsWithProposalEquivocation: Set = new Set(); + private badAttestationOffenseKeys: Set = new Set(); /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */ private lastAttestedProposal?: CheckpointProposalCore; @@ -374,6 +385,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.handleDuplicateAttestation(info); }); + this.p2pClient.registerCheckpointAttestationCallback((attestation: CheckpointAttestation) => { + this.handleCheckpointAttestation(attestation); + }); + const myAddresses = this.getValidatorAddresses(); this.p2pClient.registerThisValidatorAddresses(myAddresses); @@ -422,10 +437,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute. // In fisherman mode, we always reexecute to validate proposals. - const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config; + const { + slashBroadcastedInvalidBlockPenalty, + slashAttestInvalidCheckpointProposalPenalty, + alwaysReexecuteBlockProposals, + fishermanMode, + } = this.config; const shouldReexecute = fishermanMode || slashBroadcastedInvalidBlockPenalty > 0n || + slashAttestInvalidCheckpointProposalPenalty > 0n || partOfCommittee || alwaysReexecuteBlockProposals || this.blobClient.canUpload(); @@ -457,15 +478,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee); } - // Slash invalid block proposals (can happen even when not in committee) if ( !escapeHatchOpen && validationResult.reason && - SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason) && - slashBroadcastedInvalidBlockPenalty > 0n + SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason) ) { - this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo); - this.slashInvalidBlock(proposal); + if (slashBroadcastedInvalidBlockPenalty > 0n) { + this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo); + this.slashInvalidBlock(proposal); + } + if (slashAttestInvalidCheckpointProposalPenalty > 0n) { + this.markInvalidProposalSlot(proposal.slotNumber); + } } return false; } @@ -696,12 +720,63 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) ]); } + private markInvalidProposalSlot(slotNumber: SlotNumber): void { + const slotKey = this.getSlotKey(slotNumber); + this.addToBoundedSet(this.slotsWithInvalidBlockProposals, slotKey, MAX_TRACKED_INVALID_PROPOSAL_SLOTS); + } + + private handleCheckpointAttestation(attestation: CheckpointAttestation): void { + const slotNumber = attestation.slotNumber; + const slotKey = this.getSlotKey(slotNumber); + if (!this.slotsWithInvalidBlockProposals.has(slotKey) || this.slotsWithProposalEquivocation.has(slotKey)) { + return; + } + + const attester = attestation.getSender(); + if (!attester) { + this.log.warn(`Cannot slash checkpoint attestation with invalid signature`, { + slotNumber, + archive: attestation.archive.toString(), + }); + return; + } + + this.slashAttestedToInvalidCheckpointProposal(slotNumber, attester); + } + + private slashAttestedToInvalidCheckpointProposal(slotNumber: SlotNumber, attester: EthAddress): void { + if (this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n) { + return; + } + + const offenseKey = `${this.getSlotKey(slotNumber)}:${attester.toString()}`; + if (!this.addToBoundedSet(this.badAttestationOffenseKeys, offenseKey, MAX_TRACKED_BAD_ATTESTATIONS)) { + return; + } + + this.log.warn(`Slashing attester for attesting to invalid checkpoint proposal`, { + attester: attester.toString(), + slotNumber, + }); + + this.emit(WANT_TO_SLASH_EVENT, [ + { + validator: attester, + amount: this.config.slashAttestInvalidCheckpointProposalPenalty, + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(slotNumber), + }, + ]); + } + /** * Handle detection of a duplicate proposal (equivocation). * Emits a slash event when a proposer sends multiple proposals for the same position. */ private handleDuplicateProposal(info: DuplicateProposalInfo): void { const { slot, proposer, type } = info; + const slotKey = this.getSlotKey(slot); + this.addToBoundedSet(this.slotsWithProposalEquivocation, slotKey, MAX_TRACKED_INVALID_PROPOSAL_SLOTS); this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, { proposer: proposer.toString(), @@ -718,6 +793,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) epochOrSlot: BigInt(slot), }, ]); + + this.emit(WANT_TO_CLEAR_SLASH_EVENT, [ + { + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(slot), + }, + ]); } /** @@ -742,6 +824,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) ]); } + private getSlotKey(slot: SlotNumber): string { + return slot.toString(); + } + + private addToBoundedSet(set: Set, value: string, maxSize: number): boolean { + if (set.has(value)) { + return false; + } + if (set.size >= maxSize) { + set.delete(set.values().next().value!); + } + set.add(value); + return true; + } + async createBlockProposal( blockHeader: BlockHeader, checkpointNumber: CheckpointNumber,