@aztec-labs/slasher 6.0.0-nightly.20260829

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +232 -0
  2. package/dest/config.d.ts +6 -0
  3. package/dest/config.d.ts.map +1 -0
  4. package/dest/config.js +146 -0
  5. package/dest/factory/create_facade.d.ts +19 -0
  6. package/dest/factory/create_facade.d.ts.map +1 -0
  7. package/dest/factory/create_facade.js +49 -0
  8. package/dest/factory/create_implementation.d.ts +17 -0
  9. package/dest/factory/create_implementation.d.ts.map +1 -0
  10. package/dest/factory/create_implementation.js +25 -0
  11. package/dest/factory/get_settings.d.ts +4 -0
  12. package/dest/factory/get_settings.d.ts.map +1 -0
  13. package/dest/factory/get_settings.js +36 -0
  14. package/dest/factory/index.d.ts +3 -0
  15. package/dest/factory/index.d.ts.map +1 -0
  16. package/dest/factory/index.js +2 -0
  17. package/dest/generated/slasher-defaults.d.ts +21 -0
  18. package/dest/generated/slasher-defaults.d.ts.map +1 -0
  19. package/dest/generated/slasher-defaults.js +21 -0
  20. package/dest/index.d.ts +13 -0
  21. package/dest/index.d.ts.map +1 -0
  22. package/dest/index.js +12 -0
  23. package/dest/metrics.d.ts +23 -0
  24. package/dest/metrics.d.ts.map +1 -0
  25. package/dest/metrics.js +38 -0
  26. package/dest/null_slasher_client.d.ts +16 -0
  27. package/dest/null_slasher_client.d.ts.map +1 -0
  28. package/dest/null_slasher_client.js +30 -0
  29. package/dest/own_validator_slash_monitor.d.ts +66 -0
  30. package/dest/own_validator_slash_monitor.d.ts.map +1 -0
  31. package/dest/own_validator_slash_monitor.js +163 -0
  32. package/dest/slash_offenses_collector.d.ts +46 -0
  33. package/dest/slash_offenses_collector.d.ts.map +1 -0
  34. package/dest/slash_offenses_collector.js +114 -0
  35. package/dest/slash_round_monitor.d.ts +30 -0
  36. package/dest/slash_round_monitor.d.ts.map +1 -0
  37. package/dest/slash_round_monitor.js +52 -0
  38. package/dest/slasher_client.d.ts +118 -0
  39. package/dest/slasher_client.d.ts.map +1 -0
  40. package/dest/slasher_client.js +373 -0
  41. package/dest/slasher_client_facade.d.ts +44 -0
  42. package/dest/slasher_client_facade.d.ts.map +1 -0
  43. package/dest/slasher_client_facade.js +75 -0
  44. package/dest/slasher_client_interface.d.ts +25 -0
  45. package/dest/slasher_client_interface.d.ts.map +1 -0
  46. package/dest/slasher_client_interface.js +1 -0
  47. package/dest/stores/offenses_store.d.ts +37 -0
  48. package/dest/stores/offenses_store.d.ts.map +1 -0
  49. package/dest/stores/offenses_store.js +130 -0
  50. package/dest/stores/schema_version.d.ts +2 -0
  51. package/dest/stores/schema_version.d.ts.map +1 -0
  52. package/dest/stores/schema_version.js +1 -0
  53. package/dest/test/dummy_watcher.d.ts +11 -0
  54. package/dest/test/dummy_watcher.d.ts.map +1 -0
  55. package/dest/test/dummy_watcher.js +14 -0
  56. package/dest/watcher.d.ts +28 -0
  57. package/dest/watcher.d.ts.map +1 -0
  58. package/dest/watcher.js +2 -0
  59. package/dest/watchers/attestations_block_watcher.d.ts +47 -0
  60. package/dest/watchers/attestations_block_watcher.d.ts.map +1 -0
  61. package/dest/watchers/attestations_block_watcher.js +157 -0
  62. package/dest/watchers/attested_invalid_proposal_watcher.d.ts +42 -0
  63. package/dest/watchers/attested_invalid_proposal_watcher.d.ts.map +1 -0
  64. package/dest/watchers/attested_invalid_proposal_watcher.js +117 -0
  65. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts +38 -0
  66. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts.map +1 -0
  67. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.js +138 -0
  68. package/dest/watchers/checkpoint_equivocation_watcher.d.ts +30 -0
  69. package/dest/watchers/checkpoint_equivocation_watcher.d.ts.map +1 -0
  70. package/dest/watchers/checkpoint_equivocation_watcher.js +69 -0
  71. package/dest/watchers/data_withholding_watcher.d.ts +69 -0
  72. package/dest/watchers/data_withholding_watcher.d.ts.map +1 -0
  73. package/dest/watchers/data_withholding_watcher.js +223 -0
  74. package/package.json +90 -0
  75. package/src/config.ts +177 -0
  76. package/src/factory/create_facade.ts +85 -0
  77. package/src/factory/create_implementation.ts +83 -0
  78. package/src/factory/get_settings.ts +58 -0
  79. package/src/factory/index.ts +2 -0
  80. package/src/generated/slasher-defaults.ts +23 -0
  81. package/src/index.ts +12 -0
  82. package/src/metrics.ts +58 -0
  83. package/src/null_slasher_client.ts +37 -0
  84. package/src/own_validator_slash_monitor.ts +183 -0
  85. package/src/slash_offenses_collector.ts +156 -0
  86. package/src/slash_round_monitor.ts +62 -0
  87. package/src/slasher_client.ts +477 -0
  88. package/src/slasher_client_facade.ts +99 -0
  89. package/src/slasher_client_interface.ts +31 -0
  90. package/src/stores/offenses_store.ts +173 -0
  91. package/src/stores/schema_version.ts +1 -0
  92. package/src/test/dummy_watcher.ts +20 -0
  93. package/src/watcher.ts +35 -0
  94. package/src/watchers/attestations_block_watcher.ts +199 -0
  95. package/src/watchers/attested_invalid_proposal_watcher.ts +167 -0
  96. package/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts +191 -0
  97. package/src/watchers/checkpoint_equivocation_watcher.ts +95 -0
  98. package/src/watchers/data_withholding_watcher.ts +256 -0
@@ -0,0 +1,173 @@
1
+ import { createLogger } from '@aztec-labs/foundation/log';
2
+ import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncMultiMap } from '@aztec-labs/kv-store';
3
+ import {
4
+ type Offense,
5
+ type OffenseIdentifier,
6
+ deserializeOffense,
7
+ getRoundForOffense,
8
+ serializeOffense,
9
+ } from '@aztec-labs/stdlib/slashing';
10
+
11
+ export const SCHEMA_VERSION = 1;
12
+
13
+ type ClearOffensesFilter = Pick<Offense, 'offenseType' | 'epochOrSlot'> & {
14
+ validators?: Offense['validator'][];
15
+ };
16
+
17
+ export class SlasherOffensesStore {
18
+ /** Map from offense key to offense data */
19
+ private offenses: AztecAsyncMap<string, Buffer>;
20
+
21
+ /** Multimap from round to offense keys */
22
+ private roundsOffenses: AztecAsyncMultiMap<string, string>;
23
+
24
+ private log = createLogger('slasher:store:offenses');
25
+
26
+ constructor(
27
+ private kvStore: AztecAsyncKVStore,
28
+ private settings: {
29
+ slashingRoundSize: number;
30
+ epochDuration: number;
31
+ slashOffenseExpirationRounds?: number;
32
+ },
33
+ ) {
34
+ this.offenses = kvStore.openMap('offenses');
35
+ this.roundsOffenses = kvStore.openMultiMap('rounds-offenses');
36
+ }
37
+
38
+ /** Returns all offenses */
39
+ public async getOffenses(): Promise<Offense[]> {
40
+ const offenses: Offense[] = [];
41
+ for await (const [, buffer] of this.offenses.entriesAsync()) {
42
+ offenses.push(deserializeOffense(buffer));
43
+ }
44
+ return offenses;
45
+ }
46
+
47
+ /** Returns all offenses tracked for the given round */
48
+ public async getOffensesForRound(round: bigint): Promise<Offense[]> {
49
+ const offenses: Offense[] = [];
50
+ for await (const key of this.roundsOffenses.getValuesAsync(this.getRoundKey(round))) {
51
+ const buffer = await this.offenses.getAsync(key);
52
+ if (buffer) {
53
+ const offense = deserializeOffense(buffer);
54
+ offenses.push(offense);
55
+ }
56
+ }
57
+ return offenses;
58
+ }
59
+
60
+ /** Returns whether we have seen this offense */
61
+ public async hasOffense(offense: OffenseIdentifier): Promise<boolean> {
62
+ const key = this.getOffenseKey(offense);
63
+ return (await this.offenses.getAsync(key)) !== undefined;
64
+ }
65
+
66
+ /** Adds a new offense. Returns false if the offense is already pending. */
67
+ public async addOffense(offense: Offense): Promise<boolean> {
68
+ const key = this.getOffenseKey(offense);
69
+ const round = getRoundForOffense(offense, this.settings);
70
+ const added = await this.kvStore.transactionAsync(async () => {
71
+ if ((await this.offenses.getAsync(key)) !== undefined) {
72
+ return false;
73
+ }
74
+
75
+ await this.offenses.set(key, serializeOffense(offense));
76
+ await this.roundsOffenses.set(this.getRoundKey(round), key);
77
+ return true;
78
+ });
79
+
80
+ if (added) {
81
+ this.log.trace(`Adding pending offense ${key} for round ${round}`);
82
+ }
83
+
84
+ return added;
85
+ }
86
+
87
+ /** Removes pending offenses matching the given offense type, epoch/slot, and optional validators. */
88
+ public async clearOffenses(filter: ClearOffensesFilter): Promise<number> {
89
+ return await this.kvStore.transactionAsync(async () => {
90
+ const offensesToClear = new Map<string, Offense>();
91
+
92
+ if (filter.validators && filter.validators.length > 0) {
93
+ for (const validator of filter.validators) {
94
+ const identifier = { validator, offenseType: filter.offenseType, epochOrSlot: filter.epochOrSlot };
95
+ const key = this.getOffenseKey(identifier);
96
+ const buffer = await this.offenses.getAsync(key);
97
+ if (buffer) {
98
+ offensesToClear.set(key, deserializeOffense(buffer));
99
+ }
100
+ }
101
+ } else {
102
+ for await (const [key, buffer] of this.offenses.entriesAsync()) {
103
+ const offense = deserializeOffense(buffer);
104
+ if (offense.offenseType === filter.offenseType && offense.epochOrSlot === filter.epochOrSlot) {
105
+ offensesToClear.set(key, offense);
106
+ }
107
+ }
108
+ }
109
+
110
+ if (offensesToClear.size === 0) {
111
+ return 0;
112
+ }
113
+
114
+ for (const [key, offense] of offensesToClear) {
115
+ const round = getRoundForOffense(offense, this.settings);
116
+ await this.offenses.delete(key);
117
+ await this.roundsOffenses.deleteValue(this.getRoundKey(round), key);
118
+ this.log.trace(`Cleared pending offense ${key} for round ${round}`);
119
+ }
120
+
121
+ return offensesToClear.size;
122
+ });
123
+ }
124
+
125
+ /** Prunes all offenses expired from the store */
126
+ public async clearExpiredOffenses(currentRound: bigint): Promise<number> {
127
+ const expirationRounds = this.settings.slashOffenseExpirationRounds ?? 0;
128
+ if (expirationRounds <= 0) {
129
+ return 0; // No expiration configured
130
+ }
131
+
132
+ const expiredBefore = currentRound - BigInt(expirationRounds);
133
+ if (expiredBefore < 0) {
134
+ return 0; // Not enough rounds have passed to expire anything
135
+ }
136
+
137
+ return await this.kvStore.transactionAsync(async () => {
138
+ // Collect expired offenses and rounds
139
+ const expiredRoundKeys = new Set<string>();
140
+ const expiredOffenseKeys = new Set<string>();
141
+ for await (const [roundKey, offenseKey] of this.roundsOffenses.entriesAsync({
142
+ end: this.getRoundKey(expiredBefore),
143
+ })) {
144
+ expiredOffenseKeys.add(offenseKey);
145
+ expiredRoundKeys.add(roundKey);
146
+ }
147
+
148
+ if (expiredOffenseKeys.size === 0 && expiredRoundKeys.size === 0) {
149
+ return 0; // Nothing to clean up
150
+ }
151
+
152
+ for (const key of expiredOffenseKeys) {
153
+ this.log.trace(`Deleting offense ${key}`);
154
+ await this.offenses.delete(key);
155
+ }
156
+ for (const roundKey of expiredRoundKeys) {
157
+ this.log.trace(`Deleting round info for ${roundKey}`);
158
+ await this.roundsOffenses.delete(roundKey);
159
+ }
160
+
161
+ return expiredOffenseKeys.size;
162
+ });
163
+ }
164
+
165
+ /** Generate a unique key for an offense */
166
+ private getOffenseKey(offense: OffenseIdentifier): string {
167
+ return `${offense.validator.toString()}:${offense.offenseType}:${offense.epochOrSlot}`;
168
+ }
169
+
170
+ private getRoundKey(round: bigint): string {
171
+ return round.toString().padStart(16, '0');
172
+ }
173
+ }
@@ -0,0 +1 @@
1
+ export const SCHEMA_VERSION = 1;
@@ -0,0 +1,20 @@
1
+ import type { SlasherConfig } from '@aztec-labs/stdlib/interfaces/server';
2
+ import EventEmitter from 'node:events';
3
+
4
+ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js';
5
+
6
+ export class DummyWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
7
+ public updateConfig(_config: Partial<SlasherConfig>) {}
8
+
9
+ public start() {
10
+ return Promise.resolve();
11
+ }
12
+
13
+ public stop() {
14
+ return Promise.resolve();
15
+ }
16
+
17
+ public triggerSlash(args: WantToSlashArgs[]) {
18
+ this.emit(WANT_TO_SLASH_EVENT, args);
19
+ }
20
+ }
package/src/watcher.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { EthAddress } from '@aztec-labs/foundation/eth-address';
2
+ import type { TypedEventEmitter } from '@aztec-labs/foundation/types';
3
+ import { OffenseType } from '@aztec-labs/stdlib/slashing';
4
+
5
+ import type { SlasherConfig } from './config.js';
6
+
7
+ export const WANT_TO_SLASH_EVENT = 'want-to-slash' as const;
8
+ export const WANT_TO_CLEAR_SLASH_EVENT = 'want-to-clear-slash' as const;
9
+
10
+ export interface WantToSlashArgs {
11
+ validator: EthAddress;
12
+ amount: bigint;
13
+ offenseType: OffenseType;
14
+ epochOrSlot: bigint; // Epoch number for epoch-based offenses, slot number for slot-based
15
+ }
16
+
17
+ export interface WantToClearSlashArgs {
18
+ offenseType: OffenseType;
19
+ epochOrSlot: bigint; // Epoch number for epoch-based offenses, slot number for slot-based
20
+ validators?: EthAddress[];
21
+ }
22
+
23
+ // Event map for specific, known events of a watcher
24
+ export interface WatcherEventMap {
25
+ [WANT_TO_SLASH_EVENT]: (args: WantToSlashArgs[]) => void;
26
+ [WANT_TO_CLEAR_SLASH_EVENT]: (args: WantToClearSlashArgs[]) => void;
27
+ }
28
+
29
+ export type WatcherEmitter = TypedEventEmitter<WatcherEventMap>;
30
+
31
+ export type Watcher = WatcherEmitter & {
32
+ start?: () => Promise<void>;
33
+ stop?: () => Promise<void>;
34
+ updateConfig: (config: Partial<SlasherConfig>) => void;
35
+ };
@@ -0,0 +1,199 @@
1
+ import { EpochCache } from '@aztec-labs/epoch-cache';
2
+ import { EpochNumber } from '@aztec-labs/foundation/branded-types';
3
+ import { merge, pick } from '@aztec-labs/foundation/collection';
4
+ import { type Logger, type LoggerBindings, createLogger } from '@aztec-labs/foundation/log';
5
+ import {
6
+ type DescendentOfInvalidAttestationsCheckpointEvent,
7
+ type InvalidCheckpointDetectedEvent,
8
+ type L2BlockSourceEventEmitter,
9
+ L2BlockSourceEvents,
10
+ type ValidateCheckpointNegativeResult,
11
+ } from '@aztec-labs/stdlib/block';
12
+ import { getEpochAtSlot } from '@aztec-labs/stdlib/epoch-helpers';
13
+ import { OffenseType, getOffenseTypeName } from '@aztec-labs/stdlib/slashing';
14
+ import EventEmitter from 'node:events';
15
+
16
+ import type { SlasherConfig } from '../config.js';
17
+ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js';
18
+
19
+ const AttestationsBlockWatcherConfigKeys = [
20
+ 'slashProposeDescendantOfCheckpointWithInvalidAttestationsPenalty',
21
+ 'slashProposeInvalidAttestationsPenalty',
22
+ ] as const;
23
+
24
+ type AttestationsBlockWatcherConfig = Pick<SlasherConfig, (typeof AttestationsBlockWatcherConfigKeys)[number]>;
25
+
26
+ /**
27
+ * Watches the archiver for checkpoints whose publication is itself a slashable offense.
28
+ *
29
+ * Two cases are handled, both targeting the proposer of the offending checkpoint:
30
+ *
31
+ * - Invalid-attestations checkpoint: the proposer published a checkpoint to L1 whose
32
+ * attestations are either insufficient (below quorum) or incorrect (signature from a
33
+ * non-committee member, malformed signature, etc.). Slashed via
34
+ * {@link OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS} or
35
+ * {@link OffenseType.PROPOSED_INCORRECT_ATTESTATIONS}.
36
+ *
37
+ * - Descendant of an invalid checkpoint: the proposer published a checkpoint that extends a
38
+ * previously-rejected one. The descendant may itself have valid attestations, but it is still
39
+ * unusable. Triggered by the archiver's `CheckpointBuiltOnInvalidAncestorDetected` event
40
+ * when the descendant has valid attestations (skipped before ingestion). Slashes the descendant's
41
+ * proposer via {@link OffenseType.PROPOSED_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS}.
42
+ */
43
+ export class AttestationsBlockWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
44
+ private log: Logger;
45
+ private config: AttestationsBlockWatcherConfig;
46
+
47
+ private boundHandleInvalidCheckpoint = (event: InvalidCheckpointDetectedEvent) => {
48
+ try {
49
+ this.handleInvalidCheckpoint(event);
50
+ } catch (err) {
51
+ this.log.error('Error handling invalid checkpoint', err, {
52
+ ...event.validationResult,
53
+ reason: event.validationResult.reason,
54
+ });
55
+ }
56
+ };
57
+
58
+ private boundHandleDescendantOfInvalid = (event: DescendentOfInvalidAttestationsCheckpointEvent) => {
59
+ this.handleDescendantOfInvalid(event).catch(err => {
60
+ this.log.error('Error handling descendant of invalid checkpoint', err, {
61
+ checkpointNumber: event.checkpoint.checkpointNumber,
62
+ ancestorCheckpointNumber: event.ancestorCheckpointNumber,
63
+ });
64
+ });
65
+ };
66
+
67
+ constructor(
68
+ private l2BlockSource: L2BlockSourceEventEmitter,
69
+ private epochCache: EpochCache,
70
+ config: AttestationsBlockWatcherConfig,
71
+ bindings?: LoggerBindings,
72
+ ) {
73
+ super();
74
+ this.log = createLogger('slasher:attestations-block-watcher', bindings);
75
+ this.config = pick(config, ...AttestationsBlockWatcherConfigKeys);
76
+ this.log.info('AttestationsBlockWatcher initialized');
77
+ }
78
+
79
+ public updateConfig(newConfig: Partial<AttestationsBlockWatcherConfig>) {
80
+ this.config = merge(this.config, pick(newConfig, ...AttestationsBlockWatcherConfigKeys));
81
+ this.log.verbose('AttestationsBlockWatcher config updated', this.config);
82
+ }
83
+
84
+ public start() {
85
+ this.l2BlockSource.events.on(
86
+ L2BlockSourceEvents.InvalidAttestationsCheckpointDetected,
87
+ this.boundHandleInvalidCheckpoint,
88
+ );
89
+ this.l2BlockSource.events.on(
90
+ L2BlockSourceEvents.DescendentOfInvalidAttestationsCheckpointDetected,
91
+ this.boundHandleDescendantOfInvalid,
92
+ );
93
+ return Promise.resolve();
94
+ }
95
+
96
+ public stop() {
97
+ this.l2BlockSource.events.removeListener(
98
+ L2BlockSourceEvents.InvalidAttestationsCheckpointDetected,
99
+ this.boundHandleInvalidCheckpoint,
100
+ );
101
+ this.l2BlockSource.events.removeListener(
102
+ L2BlockSourceEvents.DescendentOfInvalidAttestationsCheckpointDetected,
103
+ this.boundHandleDescendantOfInvalid,
104
+ );
105
+ return Promise.resolve();
106
+ }
107
+
108
+ /** Event handler for invalid checkpoints as reported by the archiver. Public for testing purposes. */
109
+ public handleInvalidCheckpoint(event: InvalidCheckpointDetectedEvent): void {
110
+ const { validationResult } = event;
111
+ const { reason, checkpoint } = validationResult;
112
+
113
+ this.log.verbose(`Detected invalid checkpoint ${checkpoint.checkpointNumber}`, {
114
+ ...checkpoint,
115
+ reason: validationResult.valid === false ? validationResult.reason : 'unknown',
116
+ });
117
+
118
+ const { checkpointNumber, slotNumber: slot } = checkpoint;
119
+ const epochCommitteeInfo = { ...validationResult, isEscapeHatchOpen: false };
120
+ const proposer = this.epochCache.getProposerFromEpochCommittee(epochCommitteeInfo, slot);
121
+
122
+ if (!proposer) {
123
+ this.log.warn(`No proposer found for checkpoint ${checkpointNumber} at slot ${slot}`);
124
+ return;
125
+ }
126
+
127
+ const offense = this.getOffenseFromInvalidationReason(reason);
128
+ const amount = this.config.slashProposeInvalidAttestationsPenalty;
129
+ const args: WantToSlashArgs = {
130
+ validator: proposer,
131
+ amount,
132
+ offenseType: offense,
133
+ epochOrSlot: BigInt(slot),
134
+ };
135
+
136
+ this.log.info(`Detected invalid attestations checkpoint proposer offense`, {
137
+ ...checkpoint,
138
+ reason,
139
+ validator: args.validator.toString(),
140
+ amount: args.amount,
141
+ offenseType: getOffenseTypeName(args.offenseType),
142
+ epochOrSlot: args.epochOrSlot,
143
+ });
144
+
145
+ this.emit(WANT_TO_SLASH_EVENT, [args]);
146
+ }
147
+
148
+ /**
149
+ * Event handler for valid-attestations checkpoints that build on a previously-rejected ancestor.
150
+ * The archiver emits this when ingesting the descendant, and we slash its proposer.
151
+ */
152
+ public async handleDescendantOfInvalid(event: DescendentOfInvalidAttestationsCheckpointEvent): Promise<void> {
153
+ const { checkpoint, ancestorCheckpointNumber, ancestorArchiveRoot } = event;
154
+
155
+ const slot = checkpoint.slotNumber;
156
+ const epoch = EpochNumber(getEpochAtSlot(slot, this.epochCache.getL1Constants()));
157
+ const epochCommitteeInfo = await this.epochCache.getCommitteeForEpoch(epoch);
158
+ const proposer = this.epochCache.getProposerFromEpochCommittee({ ...epochCommitteeInfo, epoch }, slot);
159
+
160
+ if (!proposer) {
161
+ this.log.warn(
162
+ `No proposer found for invalid descendant checkpoint ${checkpoint.checkpointNumber} at slot ${slot}`,
163
+ );
164
+ return;
165
+ }
166
+
167
+ const args: WantToSlashArgs = {
168
+ validator: proposer,
169
+ amount: this.config.slashProposeDescendantOfCheckpointWithInvalidAttestationsPenalty,
170
+ offenseType: OffenseType.PROPOSED_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS,
171
+ epochOrSlot: BigInt(slot),
172
+ };
173
+
174
+ this.log.info(`Detected invalid descendant checkpoint proposer offense`, {
175
+ ...checkpoint,
176
+ ancestorCheckpointNumber,
177
+ ancestorArchiveRoot: ancestorArchiveRoot.toString(),
178
+ validator: args.validator.toString(),
179
+ amount: args.amount,
180
+ offenseType: getOffenseTypeName(args.offenseType),
181
+ epochOrSlot: args.epochOrSlot,
182
+ });
183
+
184
+ this.emit(WANT_TO_SLASH_EVENT, [args]);
185
+ }
186
+
187
+ private getOffenseFromInvalidationReason(reason: ValidateCheckpointNegativeResult['reason']): OffenseType {
188
+ switch (reason) {
189
+ case 'invalid-attestation':
190
+ return OffenseType.PROPOSED_INCORRECT_ATTESTATIONS;
191
+ case 'insufficient-attestations':
192
+ return OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS;
193
+ default: {
194
+ const _: never = reason;
195
+ return OffenseType.UNKNOWN;
196
+ }
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,167 @@
1
+ import type { EpochCacheInterface } from '@aztec-labs/epoch-cache';
2
+ import { SlotNumber } from '@aztec-labs/foundation/branded-types';
3
+ import { merge, pick } from '@aztec-labs/foundation/collection';
4
+ import type { EthAddress } from '@aztec-labs/foundation/eth-address';
5
+ import { FifoSet } from '@aztec-labs/foundation/fifo-set';
6
+ import { type Logger, createLogger } from '@aztec-labs/foundation/log';
7
+ import { RunningPromise } from '@aztec-labs/foundation/running-promise';
8
+ import type { L2BlockSource } from '@aztec-labs/stdlib/block';
9
+ import type { P2PClient, SlasherConfig } from '@aztec-labs/stdlib/interfaces/server';
10
+ import type { CheckpointAttestation } from '@aztec-labs/stdlib/p2p';
11
+ import { OffenseType, getOffenseTypeName } from '@aztec-labs/stdlib/slashing';
12
+ import EventEmitter from 'node:events';
13
+
14
+ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js';
15
+
16
+ const AttestedInvalidProposalWatcherConfigKeys = ['slashAttestInvalidCheckpointProposalPenalty'] as const;
17
+
18
+ const SCAN_SLOT_LAG = 1;
19
+ const DEFAULT_SCAN_SLOT_LOOKBACK = 4;
20
+ const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
21
+
22
+ type AttestedInvalidProposalWatcherConfig = Pick<
23
+ SlasherConfig,
24
+ (typeof AttestedInvalidProposalWatcherConfigKeys)[number]
25
+ >;
26
+
27
+ type P2PCheckpointAttestationSource = Pick<P2PClient, 'getCheckpointAttestationsForSlot'>;
28
+
29
+ type AttestedInvalidProposalWatcherOptions = {
30
+ scanSlotLookback?: number;
31
+ log?: Logger;
32
+ };
33
+
34
+ export type InvalidProposalSlotSource = {
35
+ hasInvalidProposals(slot: SlotNumber): boolean;
36
+ hasProposalEquivocation(slot: SlotNumber): boolean;
37
+ };
38
+
39
+ export class AttestedInvalidProposalWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
40
+ private readonly log: Logger;
41
+ private readonly runningPromise: RunningPromise;
42
+ private readonly emittedOffenses = FifoSet.withLimit<string>(MAX_TRACKED_BAD_ATTESTATIONS);
43
+ private readonly scanSlotLookback: number;
44
+ private config: AttestedInvalidProposalWatcherConfig;
45
+ private lastScannedSlot: SlotNumber | undefined;
46
+
47
+ constructor(
48
+ private readonly p2pClient: P2PCheckpointAttestationSource,
49
+ private readonly invalidProposalSlotSource: InvalidProposalSlotSource,
50
+ private readonly l2BlockSource: Pick<L2BlockSource, 'getSyncedL2SlotNumber'>,
51
+ private readonly epochCache: Pick<EpochCacheInterface, 'getSlotNow' | 'getL1Constants'>,
52
+ config: AttestedInvalidProposalWatcherConfig,
53
+ options: AttestedInvalidProposalWatcherOptions = {},
54
+ ) {
55
+ super();
56
+ const constants = epochCache.getL1Constants();
57
+ this.log = options.log ?? createLogger('attested-invalid-proposal-watcher');
58
+ this.config = pick(config, ...AttestedInvalidProposalWatcherConfigKeys);
59
+ this.scanSlotLookback = Math.max(1, options.scanSlotLookback ?? DEFAULT_SCAN_SLOT_LOOKBACK);
60
+
61
+ const intervalMs = Math.max(1000, (constants.ethereumSlotDuration * 1000) / 4);
62
+ this.runningPromise = new RunningPromise(() => this.scan(), this.log, intervalMs);
63
+ this.log.info('AttestedInvalidProposalWatcher initialized', { scanSlotLookback: this.scanSlotLookback });
64
+ }
65
+
66
+ public updateConfig(config: Partial<SlasherConfig>): void {
67
+ this.config = merge(this.config, pick(config, ...AttestedInvalidProposalWatcherConfigKeys));
68
+ this.log.verbose('AttestedInvalidProposalWatcher config updated', this.config);
69
+ }
70
+
71
+ public start(): Promise<void> {
72
+ this.runningPromise.start();
73
+ return Promise.resolve();
74
+ }
75
+
76
+ public stop(): Promise<void> {
77
+ return this.runningPromise.stop();
78
+ }
79
+
80
+ public async scan(): Promise<void> {
81
+ const currentSlot = (await this.l2BlockSource.getSyncedL2SlotNumber()) ?? this.epochCache.getSlotNow();
82
+ // genesis
83
+ if (currentSlot <= SlotNumber(SCAN_SLOT_LAG)) {
84
+ return;
85
+ }
86
+
87
+ const newestSlotToConsider = SlotNumber(currentSlot - SCAN_SLOT_LAG);
88
+ const oldestSlot =
89
+ this.lastScannedSlot === undefined
90
+ ? SlotNumber(Math.max(0, newestSlotToConsider - this.scanSlotLookback + 1))
91
+ : SlotNumber(this.lastScannedSlot + 1);
92
+ if (oldestSlot > newestSlotToConsider) {
93
+ return;
94
+ }
95
+
96
+ for (let slot = oldestSlot; slot <= newestSlotToConsider; slot++) {
97
+ await this.scanSlot(slot);
98
+ }
99
+
100
+ this.lastScannedSlot = newestSlotToConsider;
101
+ }
102
+
103
+ /** Scans a single invalid-proposal slot. */
104
+ public async scanSlot(slot: SlotNumber): Promise<void> {
105
+ if (
106
+ this.invalidProposalSlotSource.hasProposalEquivocation(slot) ||
107
+ !this.invalidProposalSlotSource.hasInvalidProposals(slot)
108
+ ) {
109
+ return;
110
+ }
111
+
112
+ let attestations: CheckpointAttestation[];
113
+ try {
114
+ attestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot);
115
+ } catch (err) {
116
+ this.log.warn('Error getting checkpoint attestations for invalid proposal slot', { err, slot });
117
+ return;
118
+ }
119
+
120
+ const slashArgs = attestations
121
+ .map(attestation => this.getSlashArgs(slot, attestation))
122
+ .filter((args): args is WantToSlashArgs => args !== undefined)
123
+ .filter(args => this.markAsNewOffense(args));
124
+
125
+ if (slashArgs.length === 0) {
126
+ return;
127
+ }
128
+
129
+ this.log.info('Detected attestations to invalid checkpoint proposal', {
130
+ slot,
131
+ offenses: slashArgs.map(args => ({
132
+ validator: args.validator.toString(),
133
+ amount: args.amount,
134
+ offenseType: getOffenseTypeName(args.offenseType),
135
+ epochOrSlot: args.epochOrSlot,
136
+ })),
137
+ });
138
+ this.emit(WANT_TO_SLASH_EVENT, slashArgs);
139
+ }
140
+
141
+ private getSlashArgs(slot: SlotNumber, attestation: CheckpointAttestation): WantToSlashArgs | undefined {
142
+ const attester = attestation.getSender();
143
+ if (!attester) {
144
+ this.log.warn('Cannot slash checkpoint attestation with invalid signature', {
145
+ slot,
146
+ archive: attestation.archive.toString(),
147
+ });
148
+ return undefined;
149
+ }
150
+
151
+ return this.getSlashArgsForAttester(slot, attester);
152
+ }
153
+
154
+ private getSlashArgsForAttester(slot: SlotNumber, attester: EthAddress): WantToSlashArgs {
155
+ return {
156
+ validator: attester,
157
+ amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
158
+ offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
159
+ epochOrSlot: BigInt(slot),
160
+ };
161
+ }
162
+
163
+ private markAsNewOffense(args: WantToSlashArgs): boolean {
164
+ const key = `${args.validator.toString()}-${args.offenseType}-${args.epochOrSlot}`;
165
+ return this.emittedOffenses.addIfAbsent(key);
166
+ }
167
+ }