@aztec/slasher 0.0.1-commit.24de95ac

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