@aztec/slasher 0.0.1-commit.21caa21

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 (82) hide show
  1. package/README.md +218 -0
  2. package/dest/config.d.ts +6 -0
  3. package/dest/config.d.ts.map +1 -0
  4. package/dest/config.js +134 -0
  5. package/dest/empire_slasher_client.d.ts +190 -0
  6. package/dest/empire_slasher_client.d.ts.map +1 -0
  7. package/dest/empire_slasher_client.js +572 -0
  8. package/dest/factory/create_facade.d.ts +15 -0
  9. package/dest/factory/create_facade.d.ts.map +1 -0
  10. package/dest/factory/create_facade.js +23 -0
  11. package/dest/factory/create_implementation.d.ts +17 -0
  12. package/dest/factory/create_implementation.d.ts.map +1 -0
  13. package/dest/factory/create_implementation.js +73 -0
  14. package/dest/factory/get_settings.d.ts +4 -0
  15. package/dest/factory/get_settings.d.ts.map +1 -0
  16. package/dest/factory/get_settings.js +36 -0
  17. package/dest/factory/index.d.ts +3 -0
  18. package/dest/factory/index.d.ts.map +1 -0
  19. package/dest/factory/index.js +2 -0
  20. package/dest/index.d.ts +11 -0
  21. package/dest/index.d.ts.map +1 -0
  22. package/dest/index.js +10 -0
  23. package/dest/null_slasher_client.d.ts +17 -0
  24. package/dest/null_slasher_client.d.ts.map +1 -0
  25. package/dest/null_slasher_client.js +33 -0
  26. package/dest/slash_offenses_collector.d.ts +45 -0
  27. package/dest/slash_offenses_collector.d.ts.map +1 -0
  28. package/dest/slash_offenses_collector.js +94 -0
  29. package/dest/slash_round_monitor.d.ts +30 -0
  30. package/dest/slash_round_monitor.d.ts.map +1 -0
  31. package/dest/slash_round_monitor.js +52 -0
  32. package/dest/slasher_client_facade.d.ts +44 -0
  33. package/dest/slasher_client_facade.d.ts.map +1 -0
  34. package/dest/slasher_client_facade.js +76 -0
  35. package/dest/slasher_client_interface.d.ts +39 -0
  36. package/dest/slasher_client_interface.d.ts.map +1 -0
  37. package/dest/slasher_client_interface.js +4 -0
  38. package/dest/stores/offenses_store.d.ts +37 -0
  39. package/dest/stores/offenses_store.d.ts.map +1 -0
  40. package/dest/stores/offenses_store.js +105 -0
  41. package/dest/stores/payloads_store.d.ts +29 -0
  42. package/dest/stores/payloads_store.d.ts.map +1 -0
  43. package/dest/stores/payloads_store.js +125 -0
  44. package/dest/stores/schema_version.d.ts +2 -0
  45. package/dest/stores/schema_version.d.ts.map +1 -0
  46. package/dest/stores/schema_version.js +1 -0
  47. package/dest/tally_slasher_client.d.ts +125 -0
  48. package/dest/tally_slasher_client.d.ts.map +1 -0
  49. package/dest/tally_slasher_client.js +349 -0
  50. package/dest/test/dummy_watcher.d.ts +11 -0
  51. package/dest/test/dummy_watcher.d.ts.map +1 -0
  52. package/dest/test/dummy_watcher.js +14 -0
  53. package/dest/watcher.d.ts +21 -0
  54. package/dest/watcher.d.ts.map +1 -0
  55. package/dest/watcher.js +1 -0
  56. package/dest/watchers/attestations_block_watcher.d.ts +33 -0
  57. package/dest/watchers/attestations_block_watcher.d.ts.map +1 -0
  58. package/dest/watchers/attestations_block_watcher.js +136 -0
  59. package/dest/watchers/epoch_prune_watcher.d.ts +37 -0
  60. package/dest/watchers/epoch_prune_watcher.d.ts.map +1 -0
  61. package/dest/watchers/epoch_prune_watcher.js +135 -0
  62. package/package.json +90 -0
  63. package/src/config.ts +157 -0
  64. package/src/empire_slasher_client.ts +657 -0
  65. package/src/factory/create_facade.ts +52 -0
  66. package/src/factory/create_implementation.ts +159 -0
  67. package/src/factory/get_settings.ts +58 -0
  68. package/src/factory/index.ts +2 -0
  69. package/src/index.ts +10 -0
  70. package/src/null_slasher_client.ts +41 -0
  71. package/src/slash_offenses_collector.ts +118 -0
  72. package/src/slash_round_monitor.ts +62 -0
  73. package/src/slasher_client_facade.ts +101 -0
  74. package/src/slasher_client_interface.ts +46 -0
  75. package/src/stores/offenses_store.ts +145 -0
  76. package/src/stores/payloads_store.ts +146 -0
  77. package/src/stores/schema_version.ts +1 -0
  78. package/src/tally_slasher_client.ts +442 -0
  79. package/src/test/dummy_watcher.ts +21 -0
  80. package/src/watcher.ts +27 -0
  81. package/src/watchers/attestations_block_watcher.ts +181 -0
  82. package/src/watchers/epoch_prune_watcher.ts +193 -0
@@ -0,0 +1,657 @@
1
+ import { EmpireSlashingProposerContract, RollupContract, SlasherContract } from '@aztec/ethereum';
2
+ import { sumBigint } from '@aztec/foundation/bigint';
3
+ import { SlotNumber } from '@aztec/foundation/branded-types';
4
+ import { compactArray, filterAsync, maxBy, pick } from '@aztec/foundation/collection';
5
+ import { EthAddress } from '@aztec/foundation/eth-address';
6
+ import { createLogger } from '@aztec/foundation/log';
7
+ import { sleep } from '@aztec/foundation/sleep';
8
+ import type { DateProvider } from '@aztec/foundation/timer';
9
+ import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
10
+ import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
11
+ import { SlashFactoryContract } from '@aztec/stdlib/l1-contracts';
12
+ import {
13
+ type Offense,
14
+ type OffenseIdentifier,
15
+ OffenseType,
16
+ type ProposerSlashAction,
17
+ type ProposerSlashActionProvider,
18
+ type SlashPayload,
19
+ type SlashPayloadRound,
20
+ type ValidatorSlashOffense,
21
+ getFirstEligibleRoundForOffense,
22
+ getOffenseIdentifiersFromPayload,
23
+ getPenaltyForOffense,
24
+ isOffenseUncontroversial,
25
+ offenseDataComparator,
26
+ offensesToValidatorSlash,
27
+ } from '@aztec/stdlib/slashing';
28
+
29
+ import { SlashOffensesCollector, type SlashOffensesCollectorSettings } from './slash_offenses_collector.js';
30
+ import { SlashRoundMonitor } from './slash_round_monitor.js';
31
+ import type { SlasherClientInterface } from './slasher_client_interface.js';
32
+ import type { SlasherOffensesStore } from './stores/offenses_store.js';
33
+ import type { SlasherPayloadsStore } from './stores/payloads_store.js';
34
+ import type { Watcher } from './watcher.js';
35
+
36
+ /** Used to track executable payloads for each round */
37
+ export type PayloadWithRound = {
38
+ payload: EthAddress;
39
+ round: bigint;
40
+ };
41
+
42
+ /** Node configuration for the empire slasher */
43
+ export type EmpireSlasherConfig = SlasherConfig;
44
+
45
+ /** Settings used in the empire slasher client, loaded from the L1 contracts during initialization */
46
+ export type EmpireSlasherSettings = {
47
+ slashingExecutionDelayInRounds: number;
48
+ slashingPayloadLifetimeInRounds: number;
49
+ slashingRoundSize: number;
50
+ slashingQuorumSize: number;
51
+ } & Pick<
52
+ L1RollupConstants,
53
+ 'epochDuration' | 'proofSubmissionEpochs' | 'l1GenesisTime' | 'slotDuration' | 'l1StartBlock' | 'ethereumSlotDuration'
54
+ > &
55
+ SlashOffensesCollectorSettings;
56
+
57
+ /**
58
+ * The Empire Slasher client is responsible for managing slashable offenses and slash payloads
59
+ * using the Empire slashing model where fixed payloads are created and voted on.
60
+ *
61
+ * The client subscribes to several slash watchers that emit offenses and tracks them. When the slasher is the
62
+ * proposer, it aggregates pending offenses from previous rounds and creates slash payloads, or votes for previous
63
+ * slash payloads.
64
+ * Voting is handled by the sequencer publisher, the slasher client does not interact with L1 directly.
65
+ * The client also monitors slash payloads created by other nodes, and executes them when they become submittable.
66
+ *
67
+ * Payload creation and selection
68
+ * - At each L2 slot in a slashing round, the proposer for that L2 slot may vote for an existing slashing payload or
69
+ * create one of their own. Note that anyone can create a slash payload on L1, but nodes will only follow payloads
70
+ * from proposers; we could enforce this on L1, but we do not want to make any changes there if we can avoid it.
71
+ * - If it is the first L2 slot in the slashing round, there is nothing to vote for, so the proposer creates a slash
72
+ * payload and votes for it.
73
+ * - On their turn, each proposer computes a score for each payload in the round. This score is a function of the
74
+ * total offences slashed, how many votes it has received so far, and how far into the round we are. The score for a
75
+ * payload is zero if the proposer disagrees with it (see "agreement" below).
76
+ * - The proposer also computes the score for the payload they would create. If the resulting score is higher than
77
+ * any existing payload, it creates the payload. Otherwise, it votes for the one with the highest score.
78
+ *
79
+ * Collecting offences
80
+ * - Whenever a node spots a slashable offence, they store it and add it to a local collection of "pending
81
+ * offences". When a proposer needs to create a slash payload, they include all pending offences from previous
82
+ * rounds. This means an offence is **only slashable in the next round it happened** (or a future one).
83
+ * - Each offence also carries an epoch or block identifier, so we can differentiate two offences of the same kind by
84
+ * the same validator.
85
+ * - When a slash payload is flagged as executable (as in it got enough votes to be executed), nodes remove all
86
+ * slashed offences in the payload from their collection of pending offences.
87
+ * - Pending offences expire after a configurable time. This is to minimize divergences. For instance, a validator
88
+ * that has to be slashed due to inactivity 50 epochs ago will only be considered for slashing by nodes that were
89
+ * online 50 epochs ago. We propose using the validator exit window as expiration time, any value higher means that
90
+ * we may try slashing validators that have exited the set already.
91
+ *
92
+ * Agreement and scoring
93
+ * - A proposer will *agree* with a slash payload if it *agrees* with every offence in the payload, all
94
+ * *uncontroversial* offences from the past round are included, and it is below a configurable maximum size.
95
+ * - An *uncontroversial* offence is one where every node agrees that a slash is in order, regardless of any p2p
96
+ * network partitions. The only uncontroversial offence we have now is "proposing a block on L1 with invalid
97
+ * attestations".
98
+ * - A proposer will *agree* with a given offence if it is present in its list of "pending offences", and the
99
+ * slashing amount is within a configurable min-max range.
100
+ * - Slash payloads need a maximum size to ensure they can don't exceed the maximum L1 gas per tx when executed.
101
+ * This is configurable but depends on the L1 contracts implementation. When creating a payload, if there are too
102
+ * many pending offences to fit, proposers favor the offences with the highest slashing amount first, tie-breaking by
103
+ * choosing the most recent ones.
104
+ * - The scoring function will boost proposals with more agreed slashes, as well as proposals with more votes, and
105
+ * will disincentivize the creation of new proposals as the end of the round nears. This function will NOT be
106
+ * enforced on L1.
107
+ *
108
+ * Execution
109
+ * - Once a slash payload becomes executable, the next proposer is expected to execute it. If they don't, the
110
+ * following does, and so on. No gas rebate is given.
111
+ */
112
+ export class EmpireSlasherClient implements ProposerSlashActionProvider, SlasherClientInterface {
113
+ protected executablePayloads: PayloadWithRound[] = [];
114
+
115
+ private unwatchCallbacks: (() => void)[] = [];
116
+ private overridePayloadActive = false;
117
+ private offensesCollector: SlashOffensesCollector;
118
+ private roundMonitor: SlashRoundMonitor;
119
+
120
+ constructor(
121
+ private config: EmpireSlasherConfig,
122
+ private settings: EmpireSlasherSettings,
123
+ private slashFactoryContract: SlashFactoryContract,
124
+ private slashingProposer: EmpireSlashingProposerContract,
125
+ private slasher: SlasherContract,
126
+ private rollup: RollupContract,
127
+ watchers: Watcher[],
128
+ private dateProvider: DateProvider,
129
+ private offensesStore: SlasherOffensesStore,
130
+ private payloadsStore: SlasherPayloadsStore,
131
+ private log = createLogger('slasher:empire'),
132
+ ) {
133
+ this.overridePayloadActive = config.slashOverridePayload !== undefined && !config.slashOverridePayload.isZero();
134
+ this.roundMonitor = new SlashRoundMonitor(this.settings, this.dateProvider);
135
+ this.offensesCollector = new SlashOffensesCollector(config, this.settings, watchers, offensesStore);
136
+ }
137
+
138
+ public async start() {
139
+ this.log.debug('Starting Empire Slasher client...');
140
+
141
+ // Start the offenses collector
142
+ await this.offensesCollector.start();
143
+
144
+ // TODO(palla/slash): Sync any events since the last time we were offline, or since the current round started.
145
+
146
+ // Detect when a payload wins voting via PayloadSubmittable event
147
+ this.unwatchCallbacks.push(
148
+ this.slashingProposer.listenToSubmittablePayloads(
149
+ ({ payload, round }) =>
150
+ void this.handleProposalExecutable(EthAddress.fromString(payload), round).catch(err =>
151
+ this.log.error('Error handling proposalExecutable', err, { payload, round }),
152
+ ),
153
+ ),
154
+ );
155
+
156
+ // Detect when a payload is submitted via PayloadSubmitted event
157
+ this.unwatchCallbacks.push(
158
+ this.slashingProposer.listenToPayloadSubmitted(
159
+ ({ payload, round }) =>
160
+ void this.handleProposalExecuted(EthAddress.fromString(payload), round).catch(err =>
161
+ this.log.error('Error handling payloadSubmitted', err, { payload, round }),
162
+ ),
163
+ ),
164
+ );
165
+
166
+ // Detect when a payload is signalled via SignalCast event
167
+ this.unwatchCallbacks.push(
168
+ this.slashingProposer.listenToSignalCasted(
169
+ ({ payload, round, signaler }) =>
170
+ void this.handleProposalSignalled(
171
+ EthAddress.fromString(payload),
172
+ round,
173
+ EthAddress.fromString(signaler),
174
+ ).catch(err => this.log.error('Error handling proposalSignalled', err, { payload, round, signaler })),
175
+ ),
176
+ );
177
+
178
+ // Check for round changes
179
+ this.unwatchCallbacks.push(this.roundMonitor.listenToNewRound(round => this.handleNewRound(round)));
180
+
181
+ this.log.info(`Started empire slasher client`);
182
+ return Promise.resolve();
183
+ }
184
+
185
+ /**
186
+ * Allows consumers to stop the instance of the slasher client.
187
+ * 'ready' will now return 'false' and the running promise that keeps the client synced is interrupted.
188
+ */
189
+ public async stop() {
190
+ this.log.debug('Stopping Empire Slasher client...');
191
+
192
+ for (const unwatchCallback of this.unwatchCallbacks) {
193
+ unwatchCallback();
194
+ }
195
+
196
+ this.roundMonitor.stop();
197
+ await this.offensesCollector.stop();
198
+
199
+ // Viem calls eth_uninstallFilter under the hood when uninstalling event watchers, but these calls are not awaited,
200
+ // meaning that any error that happens during the uninstallation will not be caught. This causes errors during jest teardowns,
201
+ // where we stop anvil after all other processes are stopped, so sometimes the eth_uninstallFilter call fails because anvil
202
+ // is already stopped. We add a sleep here to give the uninstallation some time to complete, but the proper fix is for
203
+ // viem to await the eth_uninstallFilter calls, or to catch any errors that happen during the uninstallation.
204
+ // See https://github.com/wevm/viem/issues/3714.
205
+ await sleep(2000);
206
+ this.log.info('Empire Slasher client stopped');
207
+ }
208
+
209
+ /** Returns the current config */
210
+ public getConfig(): EmpireSlasherConfig {
211
+ return this.config;
212
+ }
213
+
214
+ /**
215
+ * Update the config of the slasher client
216
+ * @param config - The new config
217
+ */
218
+ public updateConfig(config: Partial<SlasherConfig>) {
219
+ const newConfig = { ...this.config, ...config };
220
+
221
+ // We keep this separate flag to tell us if we should be signal for the override payload: after the override payload is executed,
222
+ // the slasher goes back to using the monitored payloads to inform the sequencer publisher what payload to signal for.
223
+ // So we only want to flip back "on" the voting for override payload if config we just passed in re-set the override payload.
224
+ this.overridePayloadActive = config.slashOverridePayload !== undefined && !config.slashOverridePayload.isZero();
225
+ this.config = newConfig;
226
+ }
227
+
228
+ public getSlashPayloads(): Promise<SlashPayloadRound[]> {
229
+ return this.payloadsStore.getPayloadsForRound(this.roundMonitor.getCurrentRound().round);
230
+ }
231
+
232
+ /**
233
+ * Triggered on a time basis when we enter a new slashing round.
234
+ * Clears expired payloads and offenses from stores.
235
+ */
236
+ protected async handleNewRound(round: bigint) {
237
+ this.log.info(`Starting new slashing round ${round}`);
238
+ await this.payloadsStore.clearExpiredPayloads(round);
239
+ await this.offensesCollector.handleNewRound(round);
240
+ }
241
+
242
+ /**
243
+ * Called when we see a PayloadSubmittable event on the SlashProposer.
244
+ * Adds the proposal to the list of executable ones.
245
+ */
246
+ protected async handleProposalExecutable(payloadAddress: EthAddress, round: bigint) {
247
+ // Track this payload for execution later
248
+ this.executablePayloads.push({ payload: payloadAddress, round });
249
+ this.log.verbose(`Proposal ${payloadAddress.toString()} is executable for round ${round}`, {
250
+ payloadAddress,
251
+ round,
252
+ });
253
+
254
+ // Stop signaling for the override payload if it was elected
255
+ if (
256
+ this.overridePayloadActive &&
257
+ this.config.slashOverridePayload &&
258
+ this.config.slashOverridePayload.equals(payloadAddress)
259
+ ) {
260
+ this.overridePayloadActive = false;
261
+ }
262
+
263
+ // Load the payload to unflag all offenses to be slashed as pending
264
+ const payload =
265
+ (await this.payloadsStore.getPayload(payloadAddress)) ??
266
+ (await this.slashFactoryContract.getSlashPayloadFromEvents(payloadAddress, this.settings));
267
+ if (!payload) {
268
+ this.log.warn(`No payload found for ${payloadAddress.toString()} in round ${round}`);
269
+ return;
270
+ }
271
+
272
+ const offenses = getOffenseIdentifiersFromPayload(payload);
273
+ await this.offensesCollector.markAsSlashed(offenses);
274
+ }
275
+
276
+ /**
277
+ * Called when we see a PayloadSubmitted event on the SlashProposer.
278
+ * Removes the proposal from the list of executable ones.
279
+ */
280
+ protected handleProposalExecuted(payload: EthAddress, round: bigint) {
281
+ this.log.verbose(`Proposal ${payload.toString()} on round ${round} has been executed`);
282
+ const index = this.executablePayloads.findIndex(p => p.payload.equals(payload));
283
+ if (index !== -1) {
284
+ this.executablePayloads.splice(index, 1);
285
+ }
286
+ return Promise.resolve();
287
+ }
288
+
289
+ /**
290
+ * Called when we see a SignalCast event on the SlashProposer.
291
+ * Adds a vote for the given payload in the round.
292
+ * Retrieves the proposal if we have not seen it before.
293
+ */
294
+ protected async handleProposalSignalled(payloadAddress: EthAddress, round: bigint, signaller: EthAddress) {
295
+ const payload = await this.payloadsStore.getPayload(payloadAddress);
296
+ if (!payload) {
297
+ this.log.debug(`Fetching payload for signal at ${payloadAddress.toString()}`);
298
+ const payload = await this.slashFactoryContract.getSlashPayloadFromEvents(payloadAddress, this.settings);
299
+ if (!payload) {
300
+ this.log.warn(`No payload found for signal at ${payloadAddress.toString()}`);
301
+ return;
302
+ }
303
+ const votes = await this.slashingProposer.getPayloadSignals(
304
+ this.rollup.address,
305
+ round,
306
+ payloadAddress.toString(),
307
+ );
308
+ await this.payloadsStore.addPayload({ ...payload, votes, round });
309
+ this.log.verbose(`Added payload at ${payloadAddress}`, { ...payload, votes, round, signaller });
310
+ } else {
311
+ const votes = await this.payloadsStore.incrementPayloadVotes(payloadAddress, round);
312
+ this.log.verbose(`Added vote for payload at ${payloadAddress}`, { votes, signaller, round });
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Create a slash payload for the given round from pending offenses
318
+ * @param round - The round to create the payload for, defaults to the current round
319
+ * @returns The payload data or undefined if no offenses to slash
320
+ */
321
+ public async gatherOffensesForRound(round?: bigint): Promise<Offense[]> {
322
+ round ??= this.roundMonitor.getCurrentRound().round;
323
+
324
+ // Filter pending offenses to those that can be included in this round
325
+ const pendingOffenses = await this.offensesStore.getPendingOffenses();
326
+ const eligibleOffenses = pendingOffenses.filter(offense => this.isOffenseForRound(offense, round));
327
+
328
+ // Sort by uncontroversial first, then slash amount (descending), then detection time (ascending)
329
+ const sortedOffenses = [...eligibleOffenses].sort(offenseDataComparator);
330
+
331
+ // Take up to maxPayloadSize offenses
332
+ const { slashMaxPayloadSize } = this.config;
333
+ const selectedOffenses = sortedOffenses.slice(0, slashMaxPayloadSize);
334
+ if (selectedOffenses.length !== sortedOffenses.length) {
335
+ this.log.warn(`Offense list of ${sortedOffenses.length} truncated to max size of ${slashMaxPayloadSize}`);
336
+ }
337
+
338
+ return selectedOffenses;
339
+ }
340
+
341
+ /** Returns all pending offenses stored */
342
+ public getPendingOffenses(): Promise<Offense[]> {
343
+ return this.offensesStore.getPendingOffenses();
344
+ }
345
+
346
+ /** Get uncontroversial offenses that are expected to be present on the given round. */
347
+ protected async getPendingUncontroversialOffensesForRound(round: bigint): Promise<Offense[]> {
348
+ const pendingOffenses = await this.offensesStore.getPendingOffenses();
349
+
350
+ const filteredOffenses = pendingOffenses
351
+ .filter(offense => isOffenseUncontroversial(offense.offenseType) && this.isOffenseForRound(offense, round))
352
+ .sort(offenseDataComparator);
353
+
354
+ return filteredOffenses.slice(0, this.config.slashMaxPayloadSize);
355
+ }
356
+
357
+ /**
358
+ * Calculate score for a slash payload, bumping the votes by one, so we get the score as if we voted for it.
359
+ * @param payload - The payload to score
360
+ * @param votes - Number of votes the payload has received
361
+ * @returns The score for the payload
362
+ */
363
+ protected calculatePayloadScore(payload: Pick<SlashPayloadRound, 'votes' | 'slashes'>): bigint {
364
+ // TODO: Update this function to something smarter
365
+ return (payload.votes + 1n) * sumBigint(payload.slashes.map(o => o.amount));
366
+ }
367
+
368
+ /**
369
+ * Get the actions the proposer should take for slashing
370
+ * @param slotNumber - The current slot number
371
+ * @returns The actions to take
372
+ */
373
+ public async getProposerActions(slotNumber: SlotNumber): Promise<ProposerSlashAction[]> {
374
+ const [executeAction, proposePayloadActions] = await Promise.all([
375
+ this.getExecutePayloadAction(slotNumber),
376
+ this.getProposePayloadActions(slotNumber),
377
+ ]);
378
+
379
+ return compactArray<ProposerSlashAction>([executeAction, ...proposePayloadActions]);
380
+ }
381
+
382
+ /** Returns an execute payload action if there are any payloads ready to be executed */
383
+ protected async getExecutePayloadAction(slotNumber: SlotNumber): Promise<ProposerSlashAction | undefined> {
384
+ const { round } = this.roundMonitor.getRoundForSlot(slotNumber);
385
+ const toRemove: PayloadWithRound[] = [];
386
+
387
+ let toExecute: PayloadWithRound | undefined;
388
+ for (const payload of this.executablePayloads) {
389
+ const executableRound = payload.round + BigInt(this.settings.slashingExecutionDelayInRounds) + 1n;
390
+ if (round < executableRound) {
391
+ this.log.debug(`Payload ${payload.payload} for round ${payload.round} is not executable yet`);
392
+ continue;
393
+ }
394
+
395
+ if (payload.round + BigInt(this.settings.slashingPayloadLifetimeInRounds) < round) {
396
+ this.log.verbose(`Payload ${payload.payload} for round ${payload.round} has expired`);
397
+ toRemove.push(payload);
398
+ continue;
399
+ }
400
+
401
+ const roundInfo = await this.slashingProposer.getRoundInfo(this.rollup.address, payload.round);
402
+ if (roundInfo.executed) {
403
+ this.log.verbose(`Payload ${payload.payload} for round ${payload.round} has already been executed`);
404
+ toRemove.push(payload);
405
+ continue;
406
+ }
407
+
408
+ // Check if slashing is enabled at all
409
+ if (!(await this.slasher.isSlashingEnabled())) {
410
+ this.log.warn(`Slashing is disabled in the Slasher contract (skipping execution)`);
411
+ return undefined;
412
+ }
413
+
414
+ // Check if the slash payload is vetoed
415
+ const isVetoed = await this.slasher.isPayloadVetoed(payload.payload);
416
+
417
+ if (isVetoed) {
418
+ this.log.info(`Payload ${payload.payload} from round ${payload.round} is vetoed (skipping execution)`);
419
+ toRemove.push(payload);
420
+ continue;
421
+ }
422
+
423
+ this.log.info(`Executing payload ${payload.payload} from round ${payload.round}`);
424
+ toExecute = payload;
425
+ break;
426
+ }
427
+
428
+ // Clean up expired or executed payloads
429
+ this.executablePayloads = this.executablePayloads.filter(p => !toRemove.includes(p));
430
+ return toExecute ? { type: 'execute-empire-payload', round: toExecute.round } : undefined;
431
+ }
432
+
433
+ /** Returns a vote or create payload action based on payload scoring */
434
+ protected async getProposePayloadActions(slotNumber: SlotNumber): Promise<ProposerSlashAction[]> {
435
+ // Compute what round we are in based on the slot number
436
+ const { round, votingSlot } = this.roundMonitor.getRoundForSlot(slotNumber);
437
+ const { slashingRoundSize: roundSize, slashingQuorumSize: quorumSize } = this.settings;
438
+ const logData = { round, votingSlot, slotNumber };
439
+
440
+ // If override payload is active, vote for it
441
+ if (this.overridePayloadActive && this.config.slashOverridePayload && !this.config.slashOverridePayload.isZero()) {
442
+ this.log.info(`Overriding slash payload to ${this.config.slashOverridePayload.toString()}`, logData);
443
+ return [{ type: 'vote-empire-payload', payload: this.config.slashOverridePayload }];
444
+ }
445
+
446
+ // Check if there is a payload that has already won, if so, no need to do anything
447
+ const existingPayloads = await this.payloadsStore.getPayloadsForRound(round);
448
+ const winningPayload = existingPayloads.find(p => p.votes >= quorumSize);
449
+ if (winningPayload) {
450
+ this.log.verbose(`No need to vote as payload ${winningPayload.address} has already won`, logData);
451
+ return [];
452
+ }
453
+
454
+ // Check if we should create a new payload at this stage
455
+ // We define an initial "nomination phase" at the beginning, which depends on the number of votes needed,
456
+ // and only allow for new proposals to be created then. This ensures that no payloads are created that will
457
+ // not be able to pass. The invariant here is that a payload can be created only if there are enough slots
458
+ // left such that if half of the remaining votes are cast for it, then it will be able to pass.
459
+ const nominationPhaseDurationInSlots = BigInt(Math.floor((roundSize - quorumSize) / 2));
460
+
461
+ // Create our ideal payload from the pending offenses we have in store
462
+ let idealPayload: Pick<SlashPayloadRound, 'slashes' | 'votes' | 'address'> | undefined = undefined;
463
+ if (votingSlot <= nominationPhaseDurationInSlots) {
464
+ const idealOffenses = await this.gatherOffensesForRound(round);
465
+ idealPayload =
466
+ idealOffenses.length === 0
467
+ ? undefined
468
+ : { slashes: offensesToValidatorSlash(idealOffenses), votes: 0n, address: EthAddress.ZERO };
469
+ this.log.debug(`Collected offenses for ideal payload for round ${round}`, { ...logData, idealOffenses });
470
+ } else {
471
+ this.log.debug(`Past nomination phase, will not create new payloads for round ${round}`, logData);
472
+ }
473
+
474
+ // Find the best existing payload. We filter out those that have no chance of winning given how many voting
475
+ // slots are left in the round, and then filter by those we agree with.
476
+ const feasiblePayloads = existingPayloads.filter(
477
+ p => BigInt(quorumSize) - p.votes <= BigInt(roundSize) - BigInt(votingSlot),
478
+ );
479
+ const requiredOffenses = await this.getPendingUncontroversialOffensesForRound(round);
480
+ const agreedPayloads = await filterAsync(feasiblePayloads, p => this.agreeWithPayload(p, round, requiredOffenses));
481
+ const bestPayload = maxBy([...agreedPayloads, idealPayload], p => (p ? this.calculatePayloadScore(p) : 0));
482
+
483
+ // Debug all payloads info
484
+ if (this.log.isLevelEnabled('debug')) {
485
+ this.log.debug(`Scored payloads for round ${round}`, {
486
+ ...logData,
487
+ idealPayload,
488
+ existingPayloads: existingPayloads.map(p => ({
489
+ ...pick(p, 'address', 'votes', 'round', 'timestamp'),
490
+ score: this.calculatePayloadScore(p),
491
+ })),
492
+ feasiblePayloads: feasiblePayloads.map(p => ({
493
+ ...pick(p, 'address', 'votes', 'round', 'timestamp'),
494
+ score: this.calculatePayloadScore(p),
495
+ })),
496
+ agreedPayloads: agreedPayloads.map(p => ({
497
+ ...pick(p, 'address', 'votes', 'round', 'timestamp'),
498
+ score: this.calculatePayloadScore(p),
499
+ })),
500
+ });
501
+ }
502
+
503
+ if (bestPayload === undefined || this.calculatePayloadScore(bestPayload) === 0n) {
504
+ // No payloads to vote for, do nothing
505
+ this.log.verbose(`No suitable slash payloads to vote for in round ${round}`, {
506
+ ...logData,
507
+ existingPayloadsCount: existingPayloads.length,
508
+ feasiblePayloadsCount: feasiblePayloads.length,
509
+ agreedPayloadsCount: agreedPayloads.length,
510
+ idealPayloadSlashesCount: idealPayload?.slashes.length,
511
+ });
512
+ return [];
513
+ } else if (bestPayload === idealPayload) {
514
+ // If our ideal payload is the best, we create it (if not deployed yet) and vote for it
515
+ const { address, isDeployed } = await this.slashFactoryContract.getAddressAndIsDeployed(idealPayload.slashes);
516
+ this.log.info(`Proposing and voting for payload ${address.toString()} in round ${round}`, {
517
+ ...logData,
518
+ payload: bestPayload,
519
+ });
520
+ const createAction: ProposerSlashAction | undefined = isDeployed
521
+ ? undefined
522
+ : { type: 'create-empire-payload', data: idealPayload.slashes };
523
+ const voteAction: ProposerSlashAction = { type: 'vote-empire-payload', payload: address };
524
+ return compactArray<ProposerSlashAction>([createAction, voteAction]);
525
+ } else {
526
+ // Otherwise, vote for our favorite payload
527
+ this.log.info(`Voting for existing payload ${bestPayload.address.toString()} in round ${round}`, {
528
+ ...logData,
529
+ payload: bestPayload,
530
+ });
531
+ return [{ type: 'vote-empire-payload', payload: bestPayload.address }];
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Check if we agree with a payload:
537
+ * - We must agree with every offense in the payload
538
+ * - All uncontroversial offenses from past rounds must be included
539
+ * - Payload must be below maximum size
540
+ * - Slash amounts must be within acceptable ranges
541
+ */
542
+ protected async agreeWithPayload(
543
+ payload: SlashPayload,
544
+ round: bigint,
545
+ cachedUncontroversialOffenses?: Offense[],
546
+ ): Promise<boolean> {
547
+ // Check size limit
548
+ const maxPayloadSize = this.config.slashMaxPayloadSize;
549
+ if (payload.slashes.length > maxPayloadSize) {
550
+ this.log.verbose(
551
+ `Rejecting payload ${payload.address} since size ${payload.slashes.length} exceeds maximum ${maxPayloadSize}`,
552
+ { payload, maxPayloadSize },
553
+ );
554
+ return false;
555
+ }
556
+
557
+ // Check we agree with all offenses and proposed slash amounts, and all offenses are from past rounds
558
+ for (const slash of payload.slashes) {
559
+ for (const { offenseType, epochOrSlot } of slash.offenses) {
560
+ const offense: OffenseIdentifier = { validator: slash.validator, offenseType, epochOrSlot };
561
+ if (!(await this.offensesStore.hasPendingOffense(offense))) {
562
+ this.log.debug(`Rejecting payload ${payload.address} due to offense not found`, { offense, payload });
563
+ return false;
564
+ }
565
+ const [minRound, maxRound] = this.getRoundRangeForOffense(offense);
566
+ if (round < minRound || round > maxRound) {
567
+ this.log.debug(`Rejecting payload ${payload.address} due to offense not from valid round`, {
568
+ offense,
569
+ payload,
570
+ round,
571
+ minRound,
572
+ maxRound,
573
+ });
574
+ return false;
575
+ }
576
+ }
577
+ const [minSlashAmount, maxSlashAmount] = this.getSlashAmountValidRange(slash.offenses);
578
+ if (slash.amount < minSlashAmount || slash.amount > maxSlashAmount) {
579
+ this.log.debug(`Rejecting payload ${payload.address} due to slash amount out of range`, {
580
+ amount: slash.amount,
581
+ minSlashAmount,
582
+ maxSlashAmount,
583
+ payload,
584
+ });
585
+ return false;
586
+ }
587
+ }
588
+
589
+ // Check that all uncontroversial offenses from past rounds are included
590
+ const uncontroversialOffenses =
591
+ cachedUncontroversialOffenses ?? (await this.getPendingUncontroversialOffensesForRound(round));
592
+ for (const requiredOffense of uncontroversialOffenses) {
593
+ const validatorOffenses = payload.slashes
594
+ .filter(slash => slash.validator.equals(requiredOffense.validator))
595
+ .flatMap(slash => slash.offenses);
596
+ if (
597
+ !validatorOffenses.some(
598
+ o => o.offenseType === requiredOffense.offenseType && o.epochOrSlot === requiredOffense.epochOrSlot,
599
+ )
600
+ ) {
601
+ this.log.debug(
602
+ `Rejecting payload due to missing uncontroversial offense for validator ${requiredOffense.validator}`,
603
+ { requiredOffense, validatorOffenses, payload },
604
+ );
605
+ return false;
606
+ }
607
+ }
608
+
609
+ return true;
610
+ }
611
+
612
+ /**
613
+ * Returns whether the given offense can be included in the given round.
614
+ * Depends on the offense round range and whether we include offenses from past rounds.
615
+ */
616
+ private isOffenseForRound(offense: OffenseIdentifier, round: bigint): boolean {
617
+ const [minRound, maxRound] = this.getRoundRangeForOffense(offense);
618
+ const match = round >= minRound && round <= maxRound;
619
+ this.log.trace(
620
+ `Offense ${offense.offenseType} for ${offense.validator} ${match ? 'is' : 'is not'} for round ${round}`,
621
+ { minRound, maxRound, round, offense },
622
+ );
623
+ return match;
624
+ }
625
+
626
+ /**
627
+ * Returns the range (inclusive) of rounds in which we could expect an offense to be found.
628
+ * Lower bound is determined by all offenses that should have been captured before the start of a round,
629
+ * which depends on the offense type (eg INACTIVITY is captured once an epoch ends, DATA_WITHHOLDING is
630
+ * captured after the epoch proof submission window for the epoch for which the data was withheld).
631
+ * Upper bound is determined by the expiration rounds for an offense, which is a config setting.
632
+ */
633
+ private getRoundRangeForOffense(offense: OffenseIdentifier): [bigint, bigint] {
634
+ const minRound = getFirstEligibleRoundForOffense(offense, this.settings);
635
+ return [minRound, minRound + BigInt(this.config.slashOffenseExpirationRounds)];
636
+ }
637
+
638
+ /** Returns the acceptable range for slash amount given a set of offenses. */
639
+ private getSlashAmountValidRange(offenses: ValidatorSlashOffense[]): [bigint, bigint] {
640
+ if (offenses.length === 0) {
641
+ return [0n, 0n];
642
+ }
643
+ const minAmount = sumBigint(offenses.map(o => this.getMinAmountForOffense(o.offenseType)));
644
+ const maxAmount = sumBigint(offenses.map(o => this.getMaxAmountForOffense(o.offenseType)));
645
+ return [minAmount, maxAmount];
646
+ }
647
+
648
+ /** Get minimum acceptable amount for an offense type */
649
+ private getMinAmountForOffense(offense: OffenseType): bigint {
650
+ return (getPenaltyForOffense(offense, this.config) * BigInt(this.config.slashMinPenaltyPercentage * 1000)) / 1000n;
651
+ }
652
+
653
+ /** Get maximum acceptable amount for an offense type */
654
+ private getMaxAmountForOffense(offense: OffenseType): bigint {
655
+ return (getPenaltyForOffense(offense, this.config) * BigInt(this.config.slashMaxPenaltyPercentage * 1000)) / 1000n;
656
+ }
657
+ }