@aztec/slasher 0.87.2-nightly.20250524

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.
@@ -0,0 +1,380 @@
1
+ import {
2
+ type ExtendedViemWalletClient,
3
+ type L1ReaderConfig,
4
+ L1TxUtils,
5
+ ProposalAlreadyExecutedError,
6
+ RollupContract,
7
+ SlashingProposerContract,
8
+ } from '@aztec/ethereum';
9
+ import { EthAddress } from '@aztec/foundation/eth-address';
10
+ import { createLogger } from '@aztec/foundation/log';
11
+ import type { DateProvider } from '@aztec/foundation/timer';
12
+ import { SlashFactoryAbi } from '@aztec/l1-artifacts';
13
+
14
+ import {
15
+ type GetContractEventsReturnType,
16
+ type GetContractReturnType,
17
+ encodeFunctionData,
18
+ getAddress,
19
+ getContract,
20
+ } from 'viem';
21
+
22
+ import {
23
+ Offence,
24
+ type SlasherConfig,
25
+ WANT_TO_SLASH_EVENT,
26
+ type WantToSlashArgs,
27
+ type Watcher,
28
+ bigIntToOffence,
29
+ } from './config.js';
30
+
31
+ type MonitoredSlashPayload = {
32
+ payloadAddress: EthAddress;
33
+ validators: readonly EthAddress[];
34
+ amounts: readonly bigint[];
35
+ offenses: readonly Offence[];
36
+ observedAtSeconds: number;
37
+ totalAmount: bigint;
38
+ };
39
+
40
+ /**
41
+ * A Spartiate slasher client implementation
42
+ *
43
+ * Spartiates: a full citizen of the ancient polis of Sparta, member of an elite warrior class.
44
+ *
45
+ * How it works:
46
+ *
47
+ * The constructor accepts instances of Watcher classes that correspond to specific offences. These "watchers" do two things:
48
+ * - watch for their offence conditions and emit an event when they are detected
49
+ * - confirm/deny whether they agree with a proposed offence
50
+ *
51
+ * The SlasherClient class is responsible for:
52
+ * - listening for events from the watchers and creating a corresponding payload
53
+ * - listening for the payloads from L1 filtering them through the watchers
54
+ * - ordering the payloads and discarding stale payloads
55
+ * - presenting the payload that ought to be currently voted for
56
+ * - detecting when it wants to execute a round
57
+ * - executing a round
58
+ * - listening for the round to be executed
59
+ * - removing the executed round from the list of monitored payloads
60
+ *
61
+ * A few improvements:
62
+ * - TODO(#14421): Only vote on the proposal if it is possible to reach quorum, e.g., if 6 votes are needed and only 4 slots are left don't vote.
63
+ */
64
+ export class SlasherClient {
65
+ private monitoredPayloads: MonitoredSlashPayload[] = [];
66
+ private unwatchCallbacks: (() => void)[] = [];
67
+
68
+ static async new(
69
+ config: SlasherConfig,
70
+ l1Contracts: Pick<L1ReaderConfig['l1Contracts'], 'rollupAddress' | 'slashFactoryAddress'>,
71
+ l1TxUtils: L1TxUtils,
72
+ watchers: Watcher[],
73
+ dateProvider: DateProvider,
74
+ ) {
75
+ if (!l1Contracts.rollupAddress) {
76
+ throw new Error('Cannot initialize SlasherClient without a rollup address');
77
+ }
78
+ if (!l1Contracts.slashFactoryAddress) {
79
+ throw new Error('Cannot initialize SlasherClient without a slashFactory address');
80
+ }
81
+
82
+ const rollup = new RollupContract(l1TxUtils.client, l1Contracts.rollupAddress);
83
+ const slashingProposer = await rollup.getSlashingProposer();
84
+ const slashFactoryContract = getContract({
85
+ address: getAddress(l1Contracts.slashFactoryAddress.toString()),
86
+ abi: SlashFactoryAbi,
87
+ client: l1TxUtils.client,
88
+ });
89
+ return new SlasherClient(config, slashFactoryContract, slashingProposer, l1TxUtils, watchers, dateProvider);
90
+ }
91
+
92
+ constructor(
93
+ public config: SlasherConfig,
94
+ protected slashFactoryContract: GetContractReturnType<typeof SlashFactoryAbi, ExtendedViemWalletClient>,
95
+ private slashingProposer: SlashingProposerContract,
96
+ private l1TxUtils: L1TxUtils,
97
+ private watchers: Watcher[],
98
+ private dateProvider: DateProvider,
99
+ private log = createLogger('slasher'),
100
+ ) {}
101
+
102
+ //////////////////// Public methods ////////////////////
103
+
104
+ public async start() {
105
+ this.log.info('Starting Slasher client...');
106
+
107
+ // detect when new payloads are created
108
+ this.unwatchCallbacks.push(this.watchSlashFactoryEvents());
109
+
110
+ // detect when a proposal is executable
111
+ this.unwatchCallbacks.push(this.slashingProposer.listenToExecutableProposals(this.executeRoundIfAgree.bind(this)));
112
+
113
+ // detect when a proposal is executed
114
+ this.unwatchCallbacks.push(this.slashingProposer.listenToProposalExecuted(this.proposalExecuted.bind(this)));
115
+
116
+ // start each watcher, who will signal the slasher client when they want to slash
117
+ const wantToSlashCb = this.wantToSlash.bind(this);
118
+ for (const watcher of this.watchers) {
119
+ if (watcher.start) {
120
+ await watcher.start();
121
+ }
122
+ watcher.on(WANT_TO_SLASH_EVENT, wantToSlashCb);
123
+ this.unwatchCallbacks.push(() => watcher.removeListener(WANT_TO_SLASH_EVENT, wantToSlashCb));
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Allows consumers to stop the instance of the slasher client.
129
+ * 'ready' will now return 'false' and the running promise that keeps the client synced is interrupted.
130
+ */
131
+ public async stop() {
132
+ this.log.debug('Stopping Slasher client...');
133
+ for (const cb of this.unwatchCallbacks) {
134
+ cb();
135
+ }
136
+ for (const watcher of this.watchers) {
137
+ if (watcher.stop) {
138
+ await watcher.stop();
139
+ }
140
+ }
141
+ this.log.info('Slasher client stopped.');
142
+ }
143
+
144
+ /**
145
+ * Get the payload to slash
146
+ *
147
+ * @param _slotNumber the current slot number (unused)
148
+ * @returns the payload to slash or undefined if there is no payload to slash
149
+ */
150
+ public getSlashPayload(_slotNumber: bigint): Promise<EthAddress | undefined> {
151
+ if (this.config.slashOverridePayload && !this.config.slashOverridePayload.isZero()) {
152
+ this.log.info(`Overriding slash payload to: ${this.config.slashOverridePayload.toString()}`);
153
+ return Promise.resolve(this.config.slashOverridePayload);
154
+ }
155
+
156
+ const currentTimeSeconds = this.dateProvider.now() / 1000;
157
+ this.filterExpiredPayloads(currentTimeSeconds, this.config.slashPayloadTtlSeconds);
158
+
159
+ if (this.monitoredPayloads.length === 0) {
160
+ this.log.debug('No monitored payloads, returning undefined');
161
+ return Promise.resolve(undefined);
162
+ }
163
+
164
+ const selectedPayload = this.monitoredPayloads[0];
165
+ this.log.info('selectedPayload', selectedPayload);
166
+
167
+ return Promise.resolve(selectedPayload.payloadAddress);
168
+ }
169
+
170
+ /**
171
+ * Get the list of monitored payloads
172
+ *
173
+ * Useful for tests.
174
+ *
175
+ * @returns the list of monitored payloads
176
+ */
177
+ public getMonitoredPayloads(): MonitoredSlashPayload[] {
178
+ return this.monitoredPayloads;
179
+ }
180
+
181
+ //////////////////// Private methods ////////////////////
182
+
183
+ /**
184
+ * This is called when a watcher emits WANT_TO_SLASH_EVENT.
185
+ *
186
+ * @param args - the arguments from the watcher, including the validators, amounts, and offenses
187
+ */
188
+ private wantToSlash(args: WantToSlashArgs) {
189
+ // TODO(#14489): need to sort the payloads by attester address
190
+ this.log.info('Wants to slash', args);
191
+ this.l1TxUtils
192
+ .sendAndMonitorTransaction({
193
+ to: this.slashFactoryContract.address,
194
+ data: encodeFunctionData({
195
+ abi: SlashFactoryAbi,
196
+ functionName: 'createSlashPayload',
197
+ args: [args.validators, args.amounts, args.offenses.map(offense => BigInt(offense))],
198
+ }),
199
+ })
200
+ // note, we don't need to monitor the logs here,
201
+ // it is handled by watchSlashFactoryEvents
202
+ .catch(e => {
203
+ this.log.error('Error slashing', e);
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Watch for new payloads created by the slash factory
209
+ *
210
+ * Whenever a log has events, we iterate over them and convert them to MonitoredSlashPayloads
211
+ *
212
+ * We then add the payloads to the list of monitored payloads if we agree with them
213
+ *
214
+ * @returns a callback to remove the watcher
215
+ */
216
+ private watchSlashFactoryEvents(): () => void {
217
+ return this.slashFactoryContract.watchEvent.SlashPayloadCreated({
218
+ onLogs: logs => {
219
+ for (const payload of this.factoryEventsToMonitoredPayloads(logs)) {
220
+ this.log.info('Slash payload created', payload);
221
+ this.addMonitoredPayload(payload).catch(e => {
222
+ this.log.error('Error adding monitored payload', e);
223
+ });
224
+ }
225
+ this.sortMonitoredPayloads();
226
+ },
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Convert a list of factory events to an iterable of monitored payloads
232
+ *
233
+ * @param args
234
+ * @returns the list of monitored payloads
235
+ */
236
+ private *factoryEventsToMonitoredPayloads(
237
+ args: GetContractEventsReturnType<typeof SlashFactoryAbi, 'SlashPayloadCreated'>,
238
+ ): IterableIterator<MonitoredSlashPayload> {
239
+ for (const event of args) {
240
+ if (!event.args) {
241
+ continue;
242
+ }
243
+ const args = event.args;
244
+ if (!args.payloadAddress || !args.validators || !args.amounts || !args.offences) {
245
+ continue;
246
+ }
247
+
248
+ yield {
249
+ payloadAddress: EthAddress.fromString(args.payloadAddress),
250
+ validators: args.validators.map(EthAddress.fromString),
251
+ amounts: args.amounts,
252
+ offenses: args.offences.map(offense => bigIntToOffence(offense)),
253
+ observedAtSeconds: this.dateProvider.now() / 1000,
254
+ totalAmount: args.amounts.reduce((acc, amount) => acc + amount, BigInt(0)),
255
+ };
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Add a payload to the list of monitored payloads if we agree with it
261
+ *
262
+ * @param payload
263
+ */
264
+ private async addMonitoredPayload(payload: MonitoredSlashPayload) {
265
+ if (await this.doIAgreeWithPayload(payload)) {
266
+ this.log.info('Adding monitored payload', payload);
267
+ this.monitoredPayloads.push(payload);
268
+ } else {
269
+ this.log.info('Disagreeing with payload', payload);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Check if we agree with a payload
275
+ *
276
+ * We check each offense and validator pair against the watchers
277
+ *
278
+ * @param payload
279
+ * @returns true if any watcher agrees with the payload, false otherwise
280
+ */
281
+ private async doIAgreeWithPayload(payload: MonitoredSlashPayload) {
282
+ // zip offenses and validators together
283
+ const offensesAndValidators = payload.offenses.map((offense, index) => ({
284
+ offense,
285
+ validator: payload.validators[index],
286
+ amount: payload.amounts[index],
287
+ }));
288
+
289
+ // check each offense
290
+ for (const offenseAndValidator of offensesAndValidators) {
291
+ const watcherResponses = await Promise.all(
292
+ this.watchers.map(watcher =>
293
+ watcher.shouldSlash(
294
+ offenseAndValidator.validator.toString(),
295
+ offenseAndValidator.amount,
296
+ offenseAndValidator.offense,
297
+ ),
298
+ ),
299
+ );
300
+ // if no watcher agrees, return false
301
+ if (watcherResponses.every(response => !response)) {
302
+ return false;
303
+ }
304
+ }
305
+ return true;
306
+ }
307
+
308
+ /**
309
+ * Sort the monitored payloads by total amount in descending order
310
+ */
311
+ private sortMonitoredPayloads() {
312
+ this.monitoredPayloads.sort((a, b) => {
313
+ const diff = b.totalAmount - a.totalAmount;
314
+ return diff > 0n ? 1 : -1;
315
+ });
316
+ }
317
+
318
+ /**
319
+ * Filter out payloads that have expired
320
+ *
321
+ * @param currentTimeSeconds
322
+ * @param payloadTtlSeconds
323
+ */
324
+ private filterExpiredPayloads(currentTimeSeconds: number, payloadTtlSeconds: number) {
325
+ this.monitoredPayloads = this.monitoredPayloads.filter(payload => {
326
+ return payload.observedAtSeconds + payloadTtlSeconds > currentTimeSeconds;
327
+ });
328
+ }
329
+
330
+ /**
331
+ * Execute a round if we agree with the proposal.
332
+ *
333
+ * Bound to the slashing proposer contract's listenToExecutableProposals method in the constructor.
334
+ *
335
+ * @param {proposal: `0x${string}`; round: bigint} param0
336
+ */
337
+ private async executeRoundIfAgree({ proposal, round }: { proposal: `0x${string}`; round: bigint }) {
338
+ const payload = EthAddress.fromString(proposal);
339
+ if (!this.monitoredPayloads.find(p => p.payloadAddress.equals(payload))) {
340
+ this.log.debug('Round executable, but we disagree', { proposal, round });
341
+ return;
342
+ }
343
+
344
+ const nextRound = round + 1n;
345
+ this.log.info(`Waiting for round ${nextRound} to be reached`);
346
+ await this.slashingProposer.waitForRound(nextRound, this.config.slashProposerRoundPollingIntervalSeconds);
347
+ this.log.info('Executing round', { proposal, round });
348
+
349
+ await this.slashingProposer
350
+ .executeRound(this.l1TxUtils, round)
351
+ .then(() => {
352
+ this.log.info('Round executed', { round });
353
+ })
354
+ .catch(err => {
355
+ if (err instanceof ProposalAlreadyExecutedError) {
356
+ this.log.debug('Round already executed', { round });
357
+ return;
358
+ }
359
+ throw err;
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Handler for when a proposal is executed.
365
+ *
366
+ * Removes the first matching payload from the list of monitored payloads.
367
+ *
368
+ * Bound to the slashing proposer contract's listenToProposalExecuted method in the constructor.
369
+ *
370
+ * @param {round: bigint; proposal: `0x${string}`} param0
371
+ */
372
+ private proposalExecuted({ round, proposal }: { round: bigint; proposal: `0x${string}` }) {
373
+ this.log.info('Proposal executed', { round, proposal });
374
+ const index = this.monitoredPayloads.findIndex(p => p.payloadAddress.equals(EthAddress.fromString(proposal)));
375
+ if (index === -1) {
376
+ return;
377
+ }
378
+ this.monitoredPayloads.splice(index, 1);
379
+ }
380
+ }