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