@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.
- package/README.md +5 -0
- package/dest/config.d.ts +40 -0
- package/dest/config.d.ts.map +1 -0
- package/dest/config.js +76 -0
- package/dest/epoch_prune_watcher.d.ts +23 -0
- package/dest/epoch_prune_watcher.d.ts.map +1 -0
- package/dest/epoch_prune_watcher.js +77 -0
- package/dest/index.d.ts +4 -0
- package/dest/index.d.ts.map +1 -0
- package/dest/index.js +3 -0
- package/dest/slasher_client.d.ts +141 -0
- package/dest/slasher_client.d.ts.map +1 -0
- package/dest/slasher_client.js +309 -0
- package/package.json +83 -0
- package/src/config.ts +118 -0
- package/src/epoch_prune_watcher.ts +94 -0
- package/src/index.ts +3 -0
- package/src/slasher_client.ts +380 -0
|
@@ -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
|
+
}
|