From 3191d0c5ddc96c475d367c053ceca1a55081ca40 Mon Sep 17 00:00:00 2001 From: Alex Gherghisan Date: Fri, 15 May 2026 10:00:47 +0000 Subject: [PATCH] fix: extend slashing of bad attestations --- .../aztec-node/src/aztec-node/server.ts | 5 +- yarn-project/slasher/README.md | 4 +- ...nvalid_checkpoint_proposal_watcher.test.ts | 155 ++++++++++++- ...ted_invalid_checkpoint_proposal_watcher.ts | 105 +++++++-- yarn-project/stdlib/src/slashing/types.ts | 2 +- .../validator-client/src/validator.test.ts | 203 ++++++++++++++++++ .../validator-client/src/validator.ts | 37 +++- 7 files changed, 479 insertions(+), 32 deletions(-) diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 95440a05ffb2..2d7fbbbb2549 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -752,7 +752,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb watchers.push(dataWithholdingWatcher); } - if (config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n) { + if ( + config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n || + config.slashAttestInvalidCheckpointProposalPenalty > 0n + ) { broadcastedInvalidCheckpointProposalWatcher = new BroadcastedInvalidCheckpointProposalWatcher( p2pClient, epochCache, diff --git a/yarn-project/slasher/README.md b/yarn-project/slasher/README.md index 12d98f2e821e..41dbf37a13fe 100644 --- a/yarn-project/slasher/README.md +++ b/yarn-project/slasher/README.md @@ -123,8 +123,8 @@ List of all slashable offenses in the system: **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. +**Description**: A committee member attested to a checkpoint proposal in a slot where this node detected a slashable invalid block or checkpoint proposal. +**Detection**: ValidatorClient marks slots with invalid block proposals detected via reexecution and invalid checkpoint proposals detected via deterministic validation, then slashes checkpoint attesters seen for that slot. BroadcastedInvalidCheckpointProposalWatcher also scans retained A-520 checkpoint evidence and retained attestations for the same 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. diff --git a/yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.test.ts b/yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.test.ts index 1bc4c1654823..95b82427e42e 100644 --- a/yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.test.ts +++ b/yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.test.ts @@ -9,6 +9,7 @@ import { OffenseType } from '@aztec/stdlib/slashing'; import { makeBlockHeader, makeBlockProposal, + makeCheckpointAttestation, makeCheckpointHeader, makeCheckpointProposal, } from '@aztec/stdlib/testing'; @@ -21,14 +22,15 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs } from '../watcher.js'; import { BroadcastedInvalidCheckpointProposalWatcher } from './broadcasted_invalid_checkpoint_proposal_watcher.js'; describe('BroadcastedInvalidCheckpointProposalWatcher', () => { - let p2pClient: MockProxy>; + let p2pClient: MockProxy>; let epochCache: MockProxy>; let config: SlasherConfig; let watcher: BroadcastedInvalidCheckpointProposalWatcher; let handler: jest.MockedFunction<(args: WantToSlashArgs[]) => void>; beforeEach(() => { - p2pClient = mock>(); + p2pClient = mock>(); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([]); epochCache = mock>(); epochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot: SlotNumber(12), nextSlot: SlotNumber(13) }); epochCache.getL1Constants.mockReturnValue({ @@ -39,6 +41,7 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => { config = { ...DefaultSlasherConfig, slashBroadcastedInvalidCheckpointProposalPenalty: 11n, + slashAttestInvalidCheckpointProposalPenalty: 13n, }; watcher = new BroadcastedInvalidCheckpointProposalWatcher(p2pClient, epochCache, config, 4); handler = jest.fn(); @@ -108,6 +111,154 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => { ]); }); + it('slashes attesters when retained attestations exist for a truncated checkpoint proposal slot', async () => { + const signer = Secp256k1Signer.random(); + const attester = Secp256k1Signer.random(); + const slot = SlotNumber(10); + const blocks = await makeBlocks(signer, slot, 4); + const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: slot }), + attesterSigner: attester, + }); + mockProposals(slot, blocks, [checkpoint]); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]); + + await watcher.scanSlot(slot); + + expect(handler).toHaveBeenCalledWith([ + { + validator: signer.address, + amount: 11n, + offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: 10n, + }, + { + validator: attester.address, + amount: config.slashAttestInvalidCheckpointProposalPenalty, + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: 10n, + }, + ]); + }); + + it('slashes attesters when proposer checkpoint slashing is disabled', async () => { + watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n }); + const signer = Secp256k1Signer.random(); + const attester = Secp256k1Signer.random(); + const slot = SlotNumber(10); + const blocks = await makeBlocks(signer, slot, 4); + const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: slot }), + attesterSigner: attester, + }); + mockProposals(slot, blocks, [checkpoint]); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]); + + await watcher.scanSlot(slot); + + expect(handler).toHaveBeenCalledWith([ + { + validator: attester.address, + amount: config.slashAttestInvalidCheckpointProposalPenalty, + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: 10n, + }, + ]); + }); + + it('does not slash attesters when bad attestation slashing is disabled', async () => { + watcher.updateConfig({ slashAttestInvalidCheckpointProposalPenalty: 0n }); + const signer = Secp256k1Signer.random(); + const attester = Secp256k1Signer.random(); + const slot = SlotNumber(10); + const blocks = await makeBlocks(signer, slot, 4); + const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: slot }), + attesterSigner: attester, + }); + mockProposals(slot, blocks, [checkpoint]); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]); + + await watcher.scanSlot(slot); + + expect(handler).toHaveBeenCalledWith([ + { + validator: signer.address, + amount: 11n, + offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: 10n, + }, + ]); + }); + + it('does not emit duplicate bad attestation offenses on repeated scans', async () => { + watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n }); + const signer = Secp256k1Signer.random(); + const attester = Secp256k1Signer.random(); + const slot = SlotNumber(10); + const blocks = await makeBlocks(signer, slot, 4); + const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: slot }), + attesterSigner: attester, + }); + mockProposals(slot, blocks, [checkpoint]); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]); + + await watcher.scanSlot(slot); + await watcher.scanSlot(slot); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('does not emit bad attestation offenses for equivocated checkpoint proposal slots', async () => { + watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n }); + const signer = Secp256k1Signer.random(); + const attester = Secp256k1Signer.random(); + const slot = SlotNumber(10); + const blocks = await makeBlocks(signer, slot, 4); + const truncatedCheckpoint = await makeCheckpointCore(signer, slot, blocks[1]); + const otherCheckpoint = await makeCheckpointCore(signer, slot, blocks[3]); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: slot }), + attesterSigner: attester, + }); + mockProposals(slot, blocks, [truncatedCheckpoint, otherCheckpoint]); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]); + + await watcher.scanSlot(slot); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not emit bad attestation offenses for equivocated block proposal slots', async () => { + watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n }); + const signer = Secp256k1Signer.random(); + const attester = Secp256k1Signer.random(); + const slot = SlotNumber(10); + const blocks = await makeBlocks(signer, slot, 4); + const equivocatedBlock = await makeBlockProposal({ + signer, + blockHeader: makeBlockHeader(99, { slotNumber: slot }), + archiveRoot: Fr.random(), + indexWithinCheckpoint: IndexWithinCheckpoint(2), + }); + const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: slot }), + attesterSigner: attester, + }); + mockProposals(slot, [...blocks, equivocatedBlock], [checkpoint]); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]); + + await watcher.scanSlot(slot); + + expect(handler).not.toHaveBeenCalled(); + }); + it('slashes when a higher-index proposal arrives after an earlier non-slashing scan', async () => { const signer = Secp256k1Signer.random(); const slot = SlotNumber(10); diff --git a/yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts b/yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts index 1bb6b6587f25..1938809db054 100644 --- a/yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts +++ b/yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts @@ -15,10 +15,12 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEm const BroadcastedInvalidCheckpointProposalWatcherConfigKeys = [ 'slashBroadcastedInvalidCheckpointProposalPenalty', + 'slashAttestInvalidCheckpointProposalPenalty', ] as const; const SCAN_SLOT_LAG = 1; const DEFAULT_SCAN_SLOT_LOOKBACK = 4; +const MAX_TRACKED_OFFENSES_PER_SLOT = 2048; type BroadcastedInvalidCheckpointProposalWatcherConfig = Pick< SlasherConfig, @@ -26,14 +28,14 @@ type BroadcastedInvalidCheckpointProposalWatcherConfig = Pick< >; type ProposalsForSlot = Awaited>; -type P2PProposalsForSlotSource = Pick; +type P2PProposalsForSlotSource = Pick; type SignedBlockProposal = { proposal: BlockProposal; signer: EthAddress; }; -/** Detects truncated-checkpoint proposal offenses from retained signed P2P proposals. */ +/** Detects A-520 truncated-checkpoint proposal offenses and associated bad attestations from retained P2P evidence. */ export class BroadcastedInvalidCheckpointProposalWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher @@ -56,10 +58,7 @@ export class BroadcastedInvalidCheckpointProposalWatcher this.config = pick(config, ...BroadcastedInvalidCheckpointProposalWatcherConfigKeys); this.scanSlotLookback = Math.max(1, scanSlotLookback); - // Bound emitted offenses to the number of slots we rescan. This watcher currently tracks one offense type, - // and at most one offense of that type can be emitted per slot. - const offenseTypes = 1; - this.emittedOffenses = FifoSet.withLimit(offenseTypes * this.scanSlotLookback); + this.emittedOffenses = FifoSet.withLimit(MAX_TRACKED_OFFENSES_PER_SLOT * this.scanSlotLookback); const intervalMs = Math.max(1000, (constants.ethereumSlotDuration * 1000) / 4); this.runningPromise = new RunningPromise(() => this.scan(), this.log, intervalMs); @@ -84,7 +83,10 @@ export class BroadcastedInvalidCheckpointProposalWatcher /** Scans newly closed slots, plus a small lookback for late-arriving proposals. */ public async scan(): Promise { - if (this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n) { + if ( + this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n && + this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n + ) { return; } @@ -106,12 +108,17 @@ export class BroadcastedInvalidCheckpointProposalWatcher /** Scans a single slot. Public for tests. */ public async scanSlot(slot: SlotNumber): Promise { - if (this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n) { + if ( + this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n && + this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n + ) { return; } const proposals = await this.p2pClient.getProposalsForSlot(slot); - const slashArgs = this.getSlashArgsForProposals(slot, proposals).filter(args => this.markAsNewOffense(args)); + const slashArgs = (await this.getSlashArgsForProposals(slot, proposals)).filter(args => + this.markAsNewOffense(args), + ); if (slashArgs.length === 0) { return; } @@ -127,15 +134,79 @@ export class BroadcastedInvalidCheckpointProposalWatcher this.emit(WANT_TO_SLASH_EVENT, slashArgs); } - private getSlashArgsForProposals(slot: SlotNumber, proposals: ProposalsForSlot): WantToSlashArgs[] { + private async getSlashArgsForProposals(slot: SlotNumber, proposals: ProposalsForSlot): Promise { const offenders = this.findOffenders(proposals.blockProposals, proposals.checkpointProposals); - // we expect one proposer per slot today. - return [...offenders.values()].map(validator => ({ - validator, - amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty, - offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL, - epochOrSlot: BigInt(slot), - })); + if (offenders.size === 0) { + return []; + } + + const proposerArgs = + this.config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n + ? [...offenders.values()].map(validator => ({ + validator, + amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty, + offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(slot), + })) + : []; + + return [...proposerArgs, ...(await this.getBadAttestationSlashArgsForProposals(slot, proposals))]; + } + + private async getBadAttestationSlashArgsForProposals( + slot: SlotNumber, + proposals: ProposalsForSlot, + ): Promise { + if (this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n || this.hasProposalEquivocation(proposals)) { + return []; + } + + let attestations: Awaited>; + try { + attestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot); + } catch (err) { + this.log.warn(`Failed to fetch checkpoint attestations for invalid checkpoint proposal slot`, { + slot, + err, + }); + return []; + } + + const args: WantToSlashArgs[] = []; + for (const attestation of attestations) { + const attester = attestation.getSender(); + if (!attester) { + continue; + } + + args.push({ + validator: attester, + amount: this.config.slashAttestInvalidCheckpointProposalPenalty, + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(slot), + }); + } + return args; + } + + private hasProposalEquivocation(proposals: ProposalsForSlot): boolean { + const checkpointProposalHashes = new Set(proposals.checkpointProposals.map(proposal => proposal.getPayloadHash())); + if (checkpointProposalHashes.size > 1) { + return true; + } + + const blockProposalHashesByPosition = new Map(); + for (const proposal of proposals.blockProposals) { + const positionKey = `${proposal.slotNumber}:${proposal.indexWithinCheckpoint}`; + const payloadHash = proposal.getPayloadHash(); + const previousPayloadHash = blockProposalHashesByPosition.get(positionKey); + if (previousPayloadHash !== undefined && previousPayloadHash !== payloadHash) { + return true; + } + blockProposalHashesByPosition.set(positionKey, payloadHash); + } + + return false; } private findOffenders(blockProposals: BlockProposal[], checkpointProposals: CheckpointProposalCore[]) { diff --git a/yarn-project/stdlib/src/slashing/types.ts b/yarn-project/stdlib/src/slashing/types.ts index 32c2ff671251..08afb87d0029 100644 --- a/yarn-project/stdlib/src/slashing/types.ts +++ b/yarn-project/stdlib/src/slashing/types.ts @@ -22,7 +22,7 @@ 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 */ + /** A committee member attested to a checkpoint proposal in a slot with an invalid block or checkpoint proposal */ ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL = 10, /** A proposer broadcast an invalid checkpoint proposal, detected by retained evidence or deterministic recomputation */ BROADCASTED_INVALID_CHECKPOINT_PROPOSAL = 11, diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index 60cf258bb212..db10965efe48 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -416,6 +416,15 @@ describe('ValidatorClient', () => { Array.isArray(args) && args[0]?.offenseType === OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL, ); + const getAttestedToInvalidCheckpointProposalSlashEvents = ( + emitSpy: jest.SpiedFunction, + ) => + emitSpy.mock.calls.filter( + ([event, args]) => + event === WANT_TO_SLASH_EVENT && + Array.isArray(args) && + args[0]?.offenseType === OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + ); beforeEach(async () => { const emptyInHash = computeInHashFromL1ToL2Messages([]); @@ -914,6 +923,65 @@ describe('ValidatorClient', () => { ]); }); + it('slashes checkpoint attestations retained before a delayed invalid checkpoint proposal is processed', async () => { + const checkpointHandler = registerAllNodesCheckpointHandler(); + const { checkpointProposal } = await makeCheckpointProposalWithHeaderMismatch(); + const attesterSigner = Secp256k1Signer.random(); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: checkpointProposal.slotNumber }), + attesterSigner, + }); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]); + const emitSpy = jest.spyOn(validatorClient, 'emit'); + + await checkpointHandler(checkpointProposal, sender); + + expect(getAttestedToInvalidCheckpointProposalSlashEvents(emitSpy)).toEqual([ + [ + WANT_TO_SLASH_EVENT, + [ + { + validator: attesterSigner.address, + amount: config.slashAttestInvalidCheckpointProposalPenalty, + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(checkpointProposal.slotNumber), + }, + ], + ], + ]); + }); + + it('slashes later checkpoint attestations after a checkpoint proposal marks the slot invalid', async () => { + await validatorClient.registerHandlers(); + const attestationCallback = p2pClient.registerCheckpointAttestationCallback.mock.calls[0][0]; + const checkpointHandler = registerAllNodesCheckpointHandler(); + const { checkpointProposal } = await makeCheckpointProposalWithHeaderMismatch(); + const emitSpy = jest.spyOn(validatorClient, 'emit'); + + await checkpointHandler(checkpointProposal, sender); + + const attesterSigner = Secp256k1Signer.random(); + const attestation = makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: checkpointProposal.slotNumber }), + attesterSigner, + }); + attestationCallback(attestation); + + expect(getAttestedToInvalidCheckpointProposalSlashEvents(emitSpy)).toEqual([ + [ + WANT_TO_SLASH_EVENT, + [ + { + validator: attesterSigner.address, + amount: config.slashAttestInvalidCheckpointProposalPenalty, + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(checkpointProposal.slotNumber), + }, + ], + ], + ]); + }); + it('emits WANT_TO_SLASH_EVENT for invalid fee asset price modifiers', async () => { const checkpointHandler = registerAllNodesCheckpointHandler(); const checkpointProposal = await makeCheckpointProposal({ @@ -968,6 +1036,38 @@ describe('ValidatorClient', () => { }, ); + it('marks invalid fee asset price modifier slots for bad attestation slashing', async () => { + const checkpointHandler = registerAllNodesCheckpointHandler(); + const checkpointProposal = await makeCheckpointProposal({ + archiveRoot: proposal.archive, + checkpointHeader: makeCheckpointHeader(0, { slotNumber: proposal.slotNumber }), + lastBlock: { + blockHeader: makeBlockHeader(1, { blockNumber: BlockNumber(123), slotNumber: proposal.slotNumber }), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + txHashes: proposal.txHashes, + }, + feeAssetPriceModifier: MAX_FEE_ASSET_PRICE_MODIFIER_BPS + 1n, + }); + const attesterSigner = Secp256k1Signer.random(); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([ + makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: checkpointProposal.slotNumber }), + attesterSigner, + }), + ]); + const emitSpy = jest.spyOn(validatorClient, 'emit'); + + await checkpointHandler(checkpointProposal, sender); + + expect(getAttestedToInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(1); + expect(getAttestedToInvalidCheckpointProposalSlashEvents(emitSpy)[0][1][0]).toEqual({ + validator: attesterSigner.address, + amount: config.slashAttestInvalidCheckpointProposalPenalty, + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(checkpointProposal.slotNumber), + }); + }); + it('does not emit checkpoint proposal slash event when the penalty is disabled', async () => { validatorClient.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n }); const checkpointHandler = registerAllNodesCheckpointHandler(); @@ -981,12 +1081,53 @@ describe('ValidatorClient', () => { expect(getBroadcastedInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(0); }); + it('slashes bad attestations when checkpoint proposer slashing is disabled', async () => { + validatorClient.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n }); + const checkpointHandler = registerAllNodesCheckpointHandler(); + const { checkpointProposal } = await makeCheckpointProposalWithHeaderMismatch(); + const attesterSigner = Secp256k1Signer.random(); + p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([ + makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: checkpointProposal.slotNumber }), + attesterSigner, + }), + ]); + const emitSpy = jest.spyOn(validatorClient, 'emit'); + + await checkpointHandler(checkpointProposal, sender); + + expect(getBroadcastedInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(0); + expect(getAttestedToInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(1); + }); + + it('does not mark checkpoint proposal slots when bad attestation slashing is disabled', async () => { + await validatorClient.registerHandlers(); + const attestationCallback = p2pClient.registerCheckpointAttestationCallback.mock.calls[0][0]; + validatorClient.updateConfig({ slashAttestInvalidCheckpointProposalPenalty: 0n }); + const checkpointHandler = registerAllNodesCheckpointHandler(); + const { checkpointProposal } = await makeCheckpointProposalWithHeaderMismatch(); + const emitSpy = jest.spyOn(validatorClient, 'emit'); + + await checkpointHandler(checkpointProposal, sender); + + attestationCallback( + makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: checkpointProposal.slotNumber }), + attesterSigner: Secp256k1Signer.random(), + }), + ); + + expect(getAttestedToInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(0); + }); + it.each([ 'last_block_not_found', 'checkpoint_already_published', 'last_block_archive_mismatch', 'checkpoint_validation_failed', ])('does not emit checkpoint proposal slash event for %s', async reason => { + await validatorClient.registerHandlers(); + const attestationCallback = p2pClient.registerCheckpointAttestationCallback.mock.calls[0][0]; const checkpointHandler = registerAllNodesCheckpointHandler(); const checkpointProposal = await makeCheckpointProposalForSlot(); jest.spyOn(validatorClient.getProposalHandler(), 'handleCheckpointProposal').mockResolvedValue({ @@ -997,7 +1138,15 @@ describe('ValidatorClient', () => { await checkpointHandler(checkpointProposal, sender); + attestationCallback( + makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: checkpointProposal.slotNumber }), + attesterSigner: Secp256k1Signer.random(), + }), + ); + expect(getBroadcastedInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(0); + expect(getAttestedToInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(0); }); it('emits checkpoint proposal slash event once for repeated invalid proposals', async () => { @@ -1011,6 +1160,60 @@ describe('ValidatorClient', () => { expect(getBroadcastedInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(1); }); + it('clears bad attestation offenses emitted for invalid checkpoint proposals when equivocation is detected', async () => { + await validatorClient.registerHandlers(); + const attestationCallback = p2pClient.registerCheckpointAttestationCallback.mock.calls[0][0]; + const duplicateProposalCallback = p2pClient.registerDuplicateProposalCallback.mock.calls[0][0]; + const checkpointHandler = registerAllNodesCheckpointHandler(); + const { checkpointProposal } = await makeCheckpointProposalWithHeaderMismatch(); + const emitSpy = jest.spyOn(validatorClient, 'emit'); + + await checkpointHandler(checkpointProposal, sender); + attestationCallback( + makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: checkpointProposal.slotNumber }), + attesterSigner: Secp256k1Signer.random(), + }), + ); + duplicateProposalCallback({ + slot: checkpointProposal.slotNumber, + proposer: checkpointProposal.getSender()!, + type: 'checkpoint', + }); + + expect(getAttestedToInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(1); + expect(emitSpy).toHaveBeenCalledWith(WANT_TO_CLEAR_SLASH_EVENT, [ + { + offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL, + epochOrSlot: BigInt(checkpointProposal.slotNumber), + }, + ]); + }); + + it('suppresses bad attestation offenses for invalid checkpoint proposal slots after equivocation is detected', async () => { + await validatorClient.registerHandlers(); + const attestationCallback = p2pClient.registerCheckpointAttestationCallback.mock.calls[0][0]; + const duplicateProposalCallback = p2pClient.registerDuplicateProposalCallback.mock.calls[0][0]; + const checkpointHandler = registerAllNodesCheckpointHandler(); + const { checkpointProposal } = await makeCheckpointProposalWithHeaderMismatch(); + const emitSpy = jest.spyOn(validatorClient, 'emit'); + + await checkpointHandler(checkpointProposal, sender); + duplicateProposalCallback({ + slot: checkpointProposal.slotNumber, + proposer: checkpointProposal.getSender()!, + type: 'checkpoint', + }); + attestationCallback( + makeCheckpointAttestation({ + header: makeCheckpointHeader(1, { slotNumber: checkpointProposal.slotNumber }), + attesterSigner: Secp256k1Signer.random(), + }), + ); + + expect(getAttestedToInvalidCheckpointProposalSlashEvents(emitSpy)).toHaveLength(0); + }); + it('emits slash event even if validator is not in the current committee', async () => { epochCache.filterInCommittee.mockResolvedValue([]); const checkpointHandler = registerAllNodesCheckpointHandler(); diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 0a0fe1d12e8f..d31734505215 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -124,7 +124,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) private lastAttestedEpochByAttester: Map = new Map(); private proposersOfInvalidBlocks = FifoSet.withLimit(MAX_PROPOSERS_OF_INVALID_BLOCKS); - private slotsWithInvalidBlockProposals = FifoSet.withLimit(MAX_TRACKED_INVALID_PROPOSAL_SLOTS); + private slotsWithInvalidProposals = FifoSet.withLimit(MAX_TRACKED_INVALID_PROPOSAL_SLOTS); private invalidCheckpointProposalOffenseKeys = FifoSet.withLimit(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS); private slotsWithProposalEquivocation = FifoSet.withLimit(MAX_TRACKED_INVALID_PROPOSAL_SLOTS); private badAttestationOffenseKeys = FifoSet.withLimit(MAX_TRACKED_BAD_ATTESTATIONS); @@ -523,8 +523,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo); this.slashInvalidBlock(proposal); } - if (slashAttestInvalidCheckpointProposalPenalty > 0n) { - this.markInvalidProposalSlot(proposal.slotNumber); + if ( + slashAttestInvalidCheckpointProposalPenalty > 0n && + this.slotsWithInvalidProposals.addIfAbsent(this.getSlotKey(proposal.slotNumber)) + ) { + await this.processRetainedCheckpointAttestations(proposal.slotNumber); } } return false; @@ -755,15 +758,22 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) ]); } - private handleInvalidCheckpointProposal( + private async handleInvalidCheckpointProposal( proposal: CheckpointProposalCore, result: CheckpointProposalValidationFailureResult, proposalInfo: LogData, - ): void { + ): Promise { if (!SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) { return; } + if ( + this.config.slashAttestInvalidCheckpointProposalPenalty > 0n && + this.slotsWithInvalidProposals.addIfAbsent(this.getSlotKey(proposal.slotNumber)) + ) { + await this.processRetainedCheckpointAttestations(proposal.slotNumber); + } + if (this.slashInvalidCheckpointProposal(proposal)) { this.log.warn(`Slashing proposer for invalid checkpoint proposal`, { ...proposalInfo, @@ -803,15 +813,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) return true; } - private markInvalidProposalSlot(slotNumber: SlotNumber): void { - const slotKey = this.getSlotKey(slotNumber); - this.slotsWithInvalidBlockProposals.add(slotKey); + private async processRetainedCheckpointAttestations(slotNumber: SlotNumber): Promise { + try { + const attestations = await this.p2pClient.getCheckpointAttestationsForSlot(slotNumber); + for (const attestation of attestations) { + this.handleCheckpointAttestation(attestation); + } + } catch (err) { + this.log.warn(`Failed to process retained checkpoint attestations for invalid proposal slot`, { + slotNumber, + err, + }); + } } private handleCheckpointAttestation(attestation: CheckpointAttestation): void { const slotNumber = attestation.slotNumber; const slotKey = this.getSlotKey(slotNumber); - if (!this.slotsWithInvalidBlockProposals.has(slotKey) || this.slotsWithProposalEquivocation.has(slotKey)) { + if (!this.slotsWithInvalidProposals.has(slotKey) || this.slotsWithProposalEquivocation.has(slotKey)) { return; }