-
Notifications
You must be signed in to change notification settings - Fork 597
feat(slasher): slash checkpoint equivocation between P2P and L1 (A-980) #23436
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: merge-train/spartan
Are you sure you want to change the base?
Changes from all commits
c653de2
399d8c3
fba7ec4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,6 +44,7 @@ import { PublicContractsDB, PublicProcessorFactory } from '@aztec/simulator/serv | |
| import { | ||
| AttestationsBlockWatcher, | ||
| BroadcastedInvalidCheckpointProposalWatcher, | ||
| CheckpointEquivocationWatcher, | ||
| DataWithholdingWatcher, | ||
| type SlasherClientInterface, | ||
| type Watcher, | ||
|
|
@@ -733,6 +734,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb | |
| let dataWithholdingWatcher: DataWithholdingWatcher | undefined; | ||
| let attestationsBlockWatcher: AttestationsBlockWatcher | undefined; | ||
| let broadcastedInvalidCheckpointProposalWatcher: BroadcastedInvalidCheckpointProposalWatcher | undefined; | ||
| let checkpointEquivocationWatcher: CheckpointEquivocationWatcher | undefined; | ||
|
|
||
| if (!proverOnly) { | ||
| validatorsSentinel = await createSentinel(epochCache, archiver, p2pClient, reexecutionTracker, config); | ||
|
|
@@ -763,6 +765,11 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb | |
| watchers.push(broadcastedInvalidCheckpointProposalWatcher); | ||
| } | ||
|
|
||
| if (config.slashDuplicateProposalPenalty > 0n) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking we should remove all these conditionals for the codebase, so the offenses get created even if they are not penalized, just for tracking purposes. But that's for another PR. |
||
| checkpointEquivocationWatcher = new CheckpointEquivocationWatcher(archiver, epochCache, config); | ||
| watchers.push(checkpointEquivocationWatcher); | ||
| } | ||
|
|
||
| // We assume we want to slash for invalid attestations unless all max penalties are set to 0 | ||
| if (config.slashProposeInvalidAttestationsPenalty > 0n || config.slashAttestDescendantOfInvalidPenalty > 0n) { | ||
| attestationsBlockWatcher = new AttestationsBlockWatcher(archiver, epochCache, config); | ||
|
|
@@ -790,6 +797,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb | |
| await broadcastedInvalidCheckpointProposalWatcher.start(); | ||
| started.push(broadcastedInvalidCheckpointProposalWatcher); | ||
| } | ||
| if (checkpointEquivocationWatcher) { | ||
| await checkpointEquivocationWatcher.start(); | ||
| started.push(checkpointEquivocationWatcher); | ||
| } | ||
| log.info(`All p2p services started`); | ||
| }) | ||
| .catch(err => log.error('Failed to start p2p services after archiver sync', err)); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import type { EpochCacheInterface } from '@aztec/epoch-cache'; | ||
| import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; | ||
| import { Fr } from '@aztec/foundation/curves/bn254'; | ||
| import { EthAddress } from '@aztec/foundation/eth-address'; | ||
| import { | ||
| type ArchiverEmitter, | ||
| type CheckpointEquivocationDetectedEvent, | ||
| type L2BlockSourceEventEmitter, | ||
| L2BlockSourceEvents, | ||
| } from '@aztec/stdlib/block'; | ||
| import { OffenseType } from '@aztec/stdlib/slashing'; | ||
|
|
||
| import { jest } from '@jest/globals'; | ||
| import { type MockProxy, mock } from 'jest-mock-extended'; | ||
| import EventEmitter from 'node:events'; | ||
|
|
||
| import { DefaultSlasherConfig, type SlasherConfig } from '../config.js'; | ||
| import { WANT_TO_SLASH_EVENT, type WantToSlashArgs } from '../watcher.js'; | ||
| import { CheckpointEquivocationWatcher } from './checkpoint_equivocation_watcher.js'; | ||
|
|
||
| describe('CheckpointEquivocationWatcher', () => { | ||
| let archiverEmitter: ArchiverEmitter; | ||
| let l2BlockSource: Pick<L2BlockSourceEventEmitter, 'events'>; | ||
| let epochCache: MockProxy<Pick<EpochCacheInterface, 'getProposerAttesterAddressInSlot'>>; | ||
| let config: SlasherConfig; | ||
| let watcher: CheckpointEquivocationWatcher; | ||
| let handler: jest.MockedFunction<(args: WantToSlashArgs[]) => void>; | ||
|
|
||
| const makeEvent = ( | ||
| overrides: Partial<CheckpointEquivocationDetectedEvent> = {}, | ||
| ): CheckpointEquivocationDetectedEvent => ({ | ||
| type: L2BlockSourceEvents.CheckpointEquivocationDetected, | ||
| slotNumber: SlotNumber(10), | ||
| checkpointNumber: CheckpointNumber(2), | ||
| l1ArchiveRoot: Fr.random(), | ||
| proposedArchiveRoot: Fr.random(), | ||
| ...overrides, | ||
| }); | ||
|
|
||
| beforeEach(async () => { | ||
| archiverEmitter = new EventEmitter() as unknown as ArchiverEmitter; | ||
| l2BlockSource = { events: archiverEmitter }; | ||
| epochCache = mock<Pick<EpochCacheInterface, 'getProposerAttesterAddressInSlot'>>(); | ||
| epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(EthAddress.random()); | ||
| config = { | ||
| ...DefaultSlasherConfig, | ||
| slashDuplicateProposalPenalty: 23n, | ||
| }; | ||
| watcher = new CheckpointEquivocationWatcher(l2BlockSource, epochCache, config); | ||
| handler = jest.fn(); | ||
| watcher.on(WANT_TO_SLASH_EVENT, handler); | ||
| await watcher.start(); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await watcher.stop(); | ||
| }); | ||
|
|
||
| const emitAndFlush = async (event: CheckpointEquivocationDetectedEvent) => { | ||
| archiverEmitter.emit(L2BlockSourceEvents.CheckpointEquivocationDetected, event); | ||
| // Allow the async handler to settle. | ||
| await new Promise(resolve => setImmediate(resolve)); | ||
| }; | ||
|
|
||
| it('emits a DUPLICATE_PROPOSAL slash for the slot proposer when divergence is detected', async () => { | ||
| const proposer = EthAddress.random(); | ||
| epochCache.getProposerAttesterAddressInSlot.mockResolvedValueOnce(proposer); | ||
|
|
||
| await emitAndFlush(makeEvent()); | ||
|
|
||
| expect(handler).toHaveBeenCalledWith([ | ||
| { | ||
| validator: proposer, | ||
| amount: 23n, | ||
| offenseType: OffenseType.DUPLICATE_PROPOSAL, | ||
| epochOrSlot: 10n, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('does not emit when there is no proposer for the slot', async () => { | ||
| epochCache.getProposerAttesterAddressInSlot.mockResolvedValueOnce(undefined); | ||
|
|
||
| await emitAndFlush(makeEvent()); | ||
|
|
||
| expect(handler).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('does not emit when the penalty is zero', async () => { | ||
| await watcher.stop(); | ||
| config = { ...config, slashDuplicateProposalPenalty: 0n }; | ||
| watcher = new CheckpointEquivocationWatcher(l2BlockSource, epochCache, config); | ||
| handler = jest.fn(); | ||
| watcher.on(WANT_TO_SLASH_EVENT, handler); | ||
| await watcher.start(); | ||
|
|
||
| await emitAndFlush(makeEvent()); | ||
|
|
||
| expect(handler).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('deduplicates repeat events for the same (validator, slot)', async () => { | ||
| const proposer = EthAddress.random(); | ||
| epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(proposer); | ||
|
|
||
| await emitAndFlush(makeEvent()); | ||
| await emitAndFlush(makeEvent()); | ||
|
|
||
| expect(handler).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('emits separately for distinct slots', async () => { | ||
| const proposer = EthAddress.random(); | ||
| epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(proposer); | ||
|
|
||
| await emitAndFlush(makeEvent({ slotNumber: SlotNumber(10) })); | ||
| await emitAndFlush(makeEvent({ slotNumber: SlotNumber(11) })); | ||
|
|
||
| expect(handler).toHaveBeenCalledTimes(2); | ||
| expect(handler.mock.calls[0][0][0].epochOrSlot).toBe(10n); | ||
| expect(handler.mock.calls[1][0][0].epochOrSlot).toBe(11n); | ||
| }); | ||
|
|
||
| it('does not slash after stop()', async () => { | ||
| await watcher.stop(); | ||
| await emitAndFlush(makeEvent()); | ||
|
|
||
| expect(handler).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we just remove this in favor of adding the offense directly from the L1 synchronizer? Sometimes I wonder whether we're over-using events, and just calling into whatever class we want is simpler to reason about. I tend to go from one approach to the other, not sure what's best here. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import type { EpochCacheInterface } from '@aztec/epoch-cache'; | ||
| import { merge, pick } from '@aztec/foundation/collection'; | ||
| import { FifoSet } from '@aztec/foundation/fifo-set'; | ||
| import { type Logger, createLogger } from '@aztec/foundation/log'; | ||
| import { | ||
| type CheckpointEquivocationDetectedEvent, | ||
| type L2BlockSourceEventEmitter, | ||
| L2BlockSourceEvents, | ||
| } from '@aztec/stdlib/block'; | ||
| import type { SlasherConfig } from '@aztec/stdlib/interfaces/server'; | ||
| import { OffenseType } from '@aztec/stdlib/slashing'; | ||
|
|
||
| import EventEmitter from 'node:events'; | ||
|
|
||
| import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js'; | ||
|
|
||
| const CheckpointEquivocationWatcherConfigKeys = ['slashDuplicateProposalPenalty'] as const; | ||
|
|
||
| const DEFAULT_EMITTED_OFFENSES_LIMIT = 64; | ||
|
|
||
| type CheckpointEquivocationWatcherConfig = Pick< | ||
| SlasherConfig, | ||
| (typeof CheckpointEquivocationWatcherConfigKeys)[number] | ||
| >; | ||
|
|
||
| type EquivocationEventSource = Pick<L2BlockSourceEventEmitter, 'events'>; | ||
| type ProposerLookup = Pick<EpochCacheInterface, 'getProposerAttesterAddressInSlot'>; | ||
|
|
||
| /** | ||
| * Slashes the slot proposer for DUPLICATE_PROPOSAL when the archiver detects that a | ||
| * locally-stored proposed checkpoint disagrees with the L1-confirmed checkpoint at the | ||
| * same slot. Both are signed by the slot proposer (the proposed one by accepting it via | ||
| * P2P or building it locally; the L1 one by submission), so the proposer equivocated. | ||
| */ | ||
| export class CheckpointEquivocationWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher { | ||
| private readonly log: Logger = createLogger('checkpoint-equivocation-watcher'); | ||
| private readonly emittedOffenses: FifoSet<string>; | ||
| private readonly handler: (args: CheckpointEquivocationDetectedEvent) => void; | ||
| private config: CheckpointEquivocationWatcherConfig; | ||
|
|
||
| constructor( | ||
| private readonly l2BlockSource: EquivocationEventSource, | ||
| private readonly epochCache: ProposerLookup, | ||
| config: CheckpointEquivocationWatcherConfig, | ||
| emittedOffensesLimit = DEFAULT_EMITTED_OFFENSES_LIMIT, | ||
| ) { | ||
| super(); | ||
| this.config = pick(config, ...CheckpointEquivocationWatcherConfigKeys); | ||
| this.emittedOffenses = FifoSet.withLimit<string>(Math.max(1, emittedOffensesLimit)); | ||
| this.handler = event => { | ||
| this.onEquivocationDetected(event).catch(err => | ||
| this.log.error('Failed to handle checkpoint equivocation event', err), | ||
| ); | ||
| }; | ||
| this.log.info('CheckpointEquivocationWatcher initialized'); | ||
| } | ||
|
|
||
| public updateConfig(config: Partial<CheckpointEquivocationWatcherConfig>): void { | ||
| this.config = merge(this.config, pick(config, ...CheckpointEquivocationWatcherConfigKeys)); | ||
| this.log.verbose('CheckpointEquivocationWatcher config updated', this.config); | ||
| } | ||
|
|
||
| public start(): Promise<void> { | ||
| this.l2BlockSource.events.on(L2BlockSourceEvents.CheckpointEquivocationDetected, this.handler); | ||
| return Promise.resolve(); | ||
| } | ||
|
|
||
| public stop(): Promise<void> { | ||
| this.l2BlockSource.events.off(L2BlockSourceEvents.CheckpointEquivocationDetected, this.handler); | ||
| return Promise.resolve(); | ||
| } | ||
|
|
||
| /** Public for tests. */ | ||
| public async onEquivocationDetected(event: CheckpointEquivocationDetectedEvent): Promise<void> { | ||
| if (this.config.slashDuplicateProposalPenalty <= 0n) { | ||
| return; | ||
| } | ||
|
|
||
| const proposer = await this.epochCache.getProposerAttesterAddressInSlot(event.slotNumber); | ||
| if (!proposer) { | ||
| this.log.warn(`Cannot attribute checkpoint equivocation: no proposer for slot ${event.slotNumber}`, { | ||
| slotNumber: event.slotNumber, | ||
| checkpointNumber: event.checkpointNumber, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| const slashArgs: WantToSlashArgs = { | ||
| validator: proposer, | ||
| amount: this.config.slashDuplicateProposalPenalty, | ||
| offenseType: OffenseType.DUPLICATE_PROPOSAL, | ||
| epochOrSlot: BigInt(event.slotNumber), | ||
| }; | ||
| if (!this.markAsNewOffense(slashArgs)) { | ||
| return; | ||
| } | ||
|
|
||
| this.log.info(`Detected checkpoint equivocation offense`, { | ||
| slotNumber: event.slotNumber, | ||
| checkpointNumber: event.checkpointNumber, | ||
| l1ArchiveRoot: event.l1ArchiveRoot.toString(), | ||
| proposedArchiveRoot: event.proposedArchiveRoot.toString(), | ||
| validator: proposer.toString(), | ||
| }); | ||
| this.emit(WANT_TO_SLASH_EVENT, [slashArgs]); | ||
| } | ||
|
|
||
| private markAsNewOffense(args: WantToSlashArgs): boolean { | ||
| const key = `${args.validator.toString()}-${args.offenseType}-${args.epochOrSlot}`; | ||
| return this.emittedOffenses.addIfAbsent(key); | ||
| } | ||
|
Comment on lines
+108
to
+111
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've just checked and the offenses store already deduplicates based on this exact same criteria, so we don't need to track it in the watcher. |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just defensively, I wouldn't trigger this if the checkpoints'
slotsare not equal. I think it can't happen, since we prune uncheckpointed checkpoints before getting here, but just in case.