@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,309 @@
1
+ import { ProposalAlreadyExecutedError, RollupContract } from '@aztec/ethereum';
2
+ import { EthAddress } from '@aztec/foundation/eth-address';
3
+ import { createLogger } from '@aztec/foundation/log';
4
+ import { SlashFactoryAbi } from '@aztec/l1-artifacts';
5
+ import { encodeFunctionData, getAddress, getContract } from 'viem';
6
+ import { WANT_TO_SLASH_EVENT, bigIntToOffence } from './config.js';
7
+ /**
8
+ * A Spartiate slasher client implementation
9
+ *
10
+ * Spartiates: a full citizen of the ancient polis of Sparta, member of an elite warrior class.
11
+ *
12
+ * How it works:
13
+ *
14
+ * The constructor accepts instances of Watcher classes that correspond to specific offences. These "watchers" do two things:
15
+ * - watch for their offence conditions and emit an event when they are detected
16
+ * - confirm/deny whether they agree with a proposed offence
17
+ *
18
+ * The SlasherClient class is responsible for:
19
+ * - listening for events from the watchers and creating a corresponding payload
20
+ * - listening for the payloads from L1 filtering them through the watchers
21
+ * - ordering the payloads and discarding stale payloads
22
+ * - presenting the payload that ought to be currently voted for
23
+ * - detecting when it wants to execute a round
24
+ * - executing a round
25
+ * - listening for the round to be executed
26
+ * - removing the executed round from the list of monitored payloads
27
+ *
28
+ * A few improvements:
29
+ * - 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.
30
+ */ export class SlasherClient {
31
+ config;
32
+ slashFactoryContract;
33
+ slashingProposer;
34
+ l1TxUtils;
35
+ watchers;
36
+ dateProvider;
37
+ log;
38
+ monitoredPayloads;
39
+ unwatchCallbacks;
40
+ static async new(config, l1Contracts, l1TxUtils, watchers, dateProvider) {
41
+ if (!l1Contracts.rollupAddress) {
42
+ throw new Error('Cannot initialize SlasherClient without a rollup address');
43
+ }
44
+ if (!l1Contracts.slashFactoryAddress) {
45
+ throw new Error('Cannot initialize SlasherClient without a slashFactory address');
46
+ }
47
+ const rollup = new RollupContract(l1TxUtils.client, l1Contracts.rollupAddress);
48
+ const slashingProposer = await rollup.getSlashingProposer();
49
+ const slashFactoryContract = getContract({
50
+ address: getAddress(l1Contracts.slashFactoryAddress.toString()),
51
+ abi: SlashFactoryAbi,
52
+ client: l1TxUtils.client
53
+ });
54
+ return new SlasherClient(config, slashFactoryContract, slashingProposer, l1TxUtils, watchers, dateProvider);
55
+ }
56
+ constructor(config, slashFactoryContract, slashingProposer, l1TxUtils, watchers, dateProvider, log = createLogger('slasher')){
57
+ this.config = config;
58
+ this.slashFactoryContract = slashFactoryContract;
59
+ this.slashingProposer = slashingProposer;
60
+ this.l1TxUtils = l1TxUtils;
61
+ this.watchers = watchers;
62
+ this.dateProvider = dateProvider;
63
+ this.log = log;
64
+ this.monitoredPayloads = [];
65
+ this.unwatchCallbacks = [];
66
+ }
67
+ //////////////////// Public methods ////////////////////
68
+ async start() {
69
+ this.log.info('Starting Slasher client...');
70
+ // detect when new payloads are created
71
+ this.unwatchCallbacks.push(this.watchSlashFactoryEvents());
72
+ // detect when a proposal is executable
73
+ this.unwatchCallbacks.push(this.slashingProposer.listenToExecutableProposals(this.executeRoundIfAgree.bind(this)));
74
+ // detect when a proposal is executed
75
+ this.unwatchCallbacks.push(this.slashingProposer.listenToProposalExecuted(this.proposalExecuted.bind(this)));
76
+ // start each watcher, who will signal the slasher client when they want to slash
77
+ const wantToSlashCb = this.wantToSlash.bind(this);
78
+ for (const watcher of this.watchers){
79
+ if (watcher.start) {
80
+ await watcher.start();
81
+ }
82
+ watcher.on(WANT_TO_SLASH_EVENT, wantToSlashCb);
83
+ this.unwatchCallbacks.push(()=>watcher.removeListener(WANT_TO_SLASH_EVENT, wantToSlashCb));
84
+ }
85
+ }
86
+ /**
87
+ * Allows consumers to stop the instance of the slasher client.
88
+ * 'ready' will now return 'false' and the running promise that keeps the client synced is interrupted.
89
+ */ async stop() {
90
+ this.log.debug('Stopping Slasher client...');
91
+ for (const cb of this.unwatchCallbacks){
92
+ cb();
93
+ }
94
+ for (const watcher of this.watchers){
95
+ if (watcher.stop) {
96
+ await watcher.stop();
97
+ }
98
+ }
99
+ this.log.info('Slasher client stopped.');
100
+ }
101
+ /**
102
+ * Get the payload to slash
103
+ *
104
+ * @param _slotNumber the current slot number (unused)
105
+ * @returns the payload to slash or undefined if there is no payload to slash
106
+ */ getSlashPayload(_slotNumber) {
107
+ if (this.config.slashOverridePayload && !this.config.slashOverridePayload.isZero()) {
108
+ this.log.info(`Overriding slash payload to: ${this.config.slashOverridePayload.toString()}`);
109
+ return Promise.resolve(this.config.slashOverridePayload);
110
+ }
111
+ const currentTimeSeconds = this.dateProvider.now() / 1000;
112
+ this.filterExpiredPayloads(currentTimeSeconds, this.config.slashPayloadTtlSeconds);
113
+ if (this.monitoredPayloads.length === 0) {
114
+ this.log.debug('No monitored payloads, returning undefined');
115
+ return Promise.resolve(undefined);
116
+ }
117
+ const selectedPayload = this.monitoredPayloads[0];
118
+ this.log.info('selectedPayload', selectedPayload);
119
+ return Promise.resolve(selectedPayload.payloadAddress);
120
+ }
121
+ /**
122
+ * Get the list of monitored payloads
123
+ *
124
+ * Useful for tests.
125
+ *
126
+ * @returns the list of monitored payloads
127
+ */ getMonitoredPayloads() {
128
+ return this.monitoredPayloads;
129
+ }
130
+ //////////////////// Private methods ////////////////////
131
+ /**
132
+ * This is called when a watcher emits WANT_TO_SLASH_EVENT.
133
+ *
134
+ * @param args - the arguments from the watcher, including the validators, amounts, and offenses
135
+ */ wantToSlash(args) {
136
+ // TODO(#14489): need to sort the payloads by attester address
137
+ this.log.info('Wants to slash', args);
138
+ this.l1TxUtils.sendAndMonitorTransaction({
139
+ to: this.slashFactoryContract.address,
140
+ data: encodeFunctionData({
141
+ abi: SlashFactoryAbi,
142
+ functionName: 'createSlashPayload',
143
+ args: [
144
+ args.validators,
145
+ args.amounts,
146
+ args.offenses.map((offense)=>BigInt(offense))
147
+ ]
148
+ })
149
+ })// note, we don't need to monitor the logs here,
150
+ // it is handled by watchSlashFactoryEvents
151
+ .catch((e)=>{
152
+ this.log.error('Error slashing', e);
153
+ });
154
+ }
155
+ /**
156
+ * Watch for new payloads created by the slash factory
157
+ *
158
+ * Whenever a log has events, we iterate over them and convert them to MonitoredSlashPayloads
159
+ *
160
+ * We then add the payloads to the list of monitored payloads if we agree with them
161
+ *
162
+ * @returns a callback to remove the watcher
163
+ */ watchSlashFactoryEvents() {
164
+ return this.slashFactoryContract.watchEvent.SlashPayloadCreated({
165
+ onLogs: (logs)=>{
166
+ for (const payload of this.factoryEventsToMonitoredPayloads(logs)){
167
+ this.log.info('Slash payload created', payload);
168
+ this.addMonitoredPayload(payload).catch((e)=>{
169
+ this.log.error('Error adding monitored payload', e);
170
+ });
171
+ }
172
+ this.sortMonitoredPayloads();
173
+ }
174
+ });
175
+ }
176
+ /**
177
+ * Convert a list of factory events to an iterable of monitored payloads
178
+ *
179
+ * @param args
180
+ * @returns the list of monitored payloads
181
+ */ *factoryEventsToMonitoredPayloads(args) {
182
+ for (const event of args){
183
+ if (!event.args) {
184
+ continue;
185
+ }
186
+ const args = event.args;
187
+ if (!args.payloadAddress || !args.validators || !args.amounts || !args.offences) {
188
+ continue;
189
+ }
190
+ yield {
191
+ payloadAddress: EthAddress.fromString(args.payloadAddress),
192
+ validators: args.validators.map(EthAddress.fromString),
193
+ amounts: args.amounts,
194
+ offenses: args.offences.map((offense)=>bigIntToOffence(offense)),
195
+ observedAtSeconds: this.dateProvider.now() / 1000,
196
+ totalAmount: args.amounts.reduce((acc, amount)=>acc + amount, BigInt(0))
197
+ };
198
+ }
199
+ }
200
+ /**
201
+ * Add a payload to the list of monitored payloads if we agree with it
202
+ *
203
+ * @param payload
204
+ */ async addMonitoredPayload(payload) {
205
+ if (await this.doIAgreeWithPayload(payload)) {
206
+ this.log.info('Adding monitored payload', payload);
207
+ this.monitoredPayloads.push(payload);
208
+ } else {
209
+ this.log.info('Disagreeing with payload', payload);
210
+ }
211
+ }
212
+ /**
213
+ * Check if we agree with a payload
214
+ *
215
+ * We check each offense and validator pair against the watchers
216
+ *
217
+ * @param payload
218
+ * @returns true if any watcher agrees with the payload, false otherwise
219
+ */ async doIAgreeWithPayload(payload) {
220
+ // zip offenses and validators together
221
+ const offensesAndValidators = payload.offenses.map((offense, index)=>({
222
+ offense,
223
+ validator: payload.validators[index],
224
+ amount: payload.amounts[index]
225
+ }));
226
+ // check each offense
227
+ for (const offenseAndValidator of offensesAndValidators){
228
+ const watcherResponses = await Promise.all(this.watchers.map((watcher)=>watcher.shouldSlash(offenseAndValidator.validator.toString(), offenseAndValidator.amount, offenseAndValidator.offense)));
229
+ // if no watcher agrees, return false
230
+ if (watcherResponses.every((response)=>!response)) {
231
+ return false;
232
+ }
233
+ }
234
+ return true;
235
+ }
236
+ /**
237
+ * Sort the monitored payloads by total amount in descending order
238
+ */ sortMonitoredPayloads() {
239
+ this.monitoredPayloads.sort((a, b)=>{
240
+ const diff = b.totalAmount - a.totalAmount;
241
+ return diff > 0n ? 1 : -1;
242
+ });
243
+ }
244
+ /**
245
+ * Filter out payloads that have expired
246
+ *
247
+ * @param currentTimeSeconds
248
+ * @param payloadTtlSeconds
249
+ */ filterExpiredPayloads(currentTimeSeconds, payloadTtlSeconds) {
250
+ this.monitoredPayloads = this.monitoredPayloads.filter((payload)=>{
251
+ return payload.observedAtSeconds + payloadTtlSeconds > currentTimeSeconds;
252
+ });
253
+ }
254
+ /**
255
+ * Execute a round if we agree with the proposal.
256
+ *
257
+ * Bound to the slashing proposer contract's listenToExecutableProposals method in the constructor.
258
+ *
259
+ * @param {proposal: `0x${string}`; round: bigint} param0
260
+ */ async executeRoundIfAgree({ proposal, round }) {
261
+ const payload = EthAddress.fromString(proposal);
262
+ if (!this.monitoredPayloads.find((p)=>p.payloadAddress.equals(payload))) {
263
+ this.log.debug('Round executable, but we disagree', {
264
+ proposal,
265
+ round
266
+ });
267
+ return;
268
+ }
269
+ const nextRound = round + 1n;
270
+ this.log.info(`Waiting for round ${nextRound} to be reached`);
271
+ await this.slashingProposer.waitForRound(nextRound, this.config.slashProposerRoundPollingIntervalSeconds);
272
+ this.log.info('Executing round', {
273
+ proposal,
274
+ round
275
+ });
276
+ await this.slashingProposer.executeRound(this.l1TxUtils, round).then(()=>{
277
+ this.log.info('Round executed', {
278
+ round
279
+ });
280
+ }).catch((err)=>{
281
+ if (err instanceof ProposalAlreadyExecutedError) {
282
+ this.log.debug('Round already executed', {
283
+ round
284
+ });
285
+ return;
286
+ }
287
+ throw err;
288
+ });
289
+ }
290
+ /**
291
+ * Handler for when a proposal is executed.
292
+ *
293
+ * Removes the first matching payload from the list of monitored payloads.
294
+ *
295
+ * Bound to the slashing proposer contract's listenToProposalExecuted method in the constructor.
296
+ *
297
+ * @param {round: bigint; proposal: `0x${string}`} param0
298
+ */ proposalExecuted({ round, proposal }) {
299
+ this.log.info('Proposal executed', {
300
+ round,
301
+ proposal
302
+ });
303
+ const index = this.monitoredPayloads.findIndex((p)=>p.payloadAddress.equals(EthAddress.fromString(proposal)));
304
+ if (index === -1) {
305
+ return;
306
+ }
307
+ this.monitoredPayloads.splice(index, 1);
308
+ }
309
+ }
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@aztec/slasher",
3
+ "version": "0.87.2-nightly.20250524",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./dest/index.js",
7
+ "./config": "./dest/config.js"
8
+ },
9
+ "inherits": [
10
+ "../package.common.json"
11
+ ],
12
+ "scripts": {
13
+ "build": "yarn clean && tsc -b",
14
+ "build:dev": "tsc -b --watch",
15
+ "clean": "rm -rf ./dest .tsbuildinfo",
16
+ "bb": "node --no-warnings ./dest/bb/index.js",
17
+ "test": "NODE_NO_WARNINGS=1 node --experimental-vm-modules ../node_modules/.bin/jest --passWithNoTests --maxWorkers=${JEST_MAX_WORKERS:-8}"
18
+ },
19
+ "jest": {
20
+ "moduleNameMapper": {
21
+ "^(\\.{1,2}/.*)\\.[cm]?js$": "$1"
22
+ },
23
+ "testRegex": "./src/.*\\.test\\.(js|mjs|ts)$",
24
+ "rootDir": "./src",
25
+ "transform": {
26
+ "^.+\\.tsx?$": [
27
+ "@swc/jest",
28
+ {
29
+ "jsc": {
30
+ "parser": {
31
+ "syntax": "typescript",
32
+ "decorators": true
33
+ },
34
+ "transform": {
35
+ "decoratorVersion": "2022-03"
36
+ }
37
+ }
38
+ }
39
+ ]
40
+ },
41
+ "extensionsToTreatAsEsm": [
42
+ ".ts"
43
+ ],
44
+ "reporters": [
45
+ "default"
46
+ ],
47
+ "testTimeout": 120000,
48
+ "setupFiles": [
49
+ "../../foundation/src/jest/setup.mjs"
50
+ ]
51
+ },
52
+ "dependencies": {
53
+ "@aztec/epoch-cache": "0.87.2-nightly.20250524",
54
+ "@aztec/ethereum": "0.87.2-nightly.20250524",
55
+ "@aztec/foundation": "0.87.2-nightly.20250524",
56
+ "@aztec/l1-artifacts": "0.87.2-nightly.20250524",
57
+ "@aztec/stdlib": "0.87.2-nightly.20250524",
58
+ "@aztec/telemetry-client": "0.87.2-nightly.20250524",
59
+ "source-map-support": "^0.5.21",
60
+ "tslib": "^2.4.0",
61
+ "viem": "2.23.7",
62
+ "zod": "^3.23.8"
63
+ },
64
+ "devDependencies": {
65
+ "@jest/globals": "^29.5.0",
66
+ "@types/jest": "^29.5.0",
67
+ "@types/node": "^22.15.17",
68
+ "@types/source-map-support": "^0.5.10",
69
+ "jest": "^29.5.0",
70
+ "jest-mock-extended": "^3.0.3",
71
+ "ts-node": "^10.9.1",
72
+ "typescript": "^5.3.3"
73
+ },
74
+ "files": [
75
+ "dest",
76
+ "src",
77
+ "!*.test.*"
78
+ ],
79
+ "types": "./dest/index.d.ts",
80
+ "engines": {
81
+ "node": ">=20.10"
82
+ }
83
+ }
package/src/config.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type { ConfigMappingsType } from '@aztec/foundation/config';
2
+ import { bigintConfigHelper, booleanConfigHelper, numberConfigHelper } from '@aztec/foundation/config';
3
+ import { EthAddress } from '@aztec/foundation/eth-address';
4
+ import type { TypedEventEmitter } from '@aztec/foundation/types';
5
+
6
+ export enum Offence {
7
+ UNKNOWN = 0,
8
+ EPOCH_PRUNE = 1,
9
+ INACTIVITY = 2,
10
+ }
11
+
12
+ export const OffenceToBigInt: Record<Offence, bigint> = {
13
+ [Offence.UNKNOWN]: 0n,
14
+ [Offence.EPOCH_PRUNE]: 1n,
15
+ [Offence.INACTIVITY]: 2n,
16
+ };
17
+
18
+ export function bigIntToOffence(offense: bigint): Offence {
19
+ switch (offense) {
20
+ case 0n:
21
+ return Offence.UNKNOWN;
22
+ case 1n:
23
+ return Offence.EPOCH_PRUNE;
24
+ case 2n:
25
+ return Offence.INACTIVITY;
26
+ default:
27
+ throw new Error(`Unknown offence: ${offense}`);
28
+ }
29
+ }
30
+
31
+ export const WANT_TO_SLASH_EVENT = 'wantToSlash' as const;
32
+
33
+ export interface WantToSlashArgs {
34
+ validators: `0x${string}`[] | readonly `0x${string}`[];
35
+ amounts: bigint[];
36
+ offenses: Offence[];
37
+ }
38
+
39
+ // Event map for specific, known events of a watcher
40
+ export interface WatcherEventMap {
41
+ [WANT_TO_SLASH_EVENT]: (args: WantToSlashArgs) => void;
42
+ }
43
+
44
+ export type WatcherEmitter = TypedEventEmitter<WatcherEventMap>;
45
+
46
+ export type CheckSlashFn = (validator: `0x${string}`, amount: bigint, offense: Offence) => Promise<boolean>;
47
+
48
+ export type Watcher = WatcherEmitter & {
49
+ shouldSlash: CheckSlashFn;
50
+ start?: () => Promise<void>;
51
+ stop?: () => Promise<void>;
52
+ };
53
+
54
+ export interface SlasherConfig {
55
+ // New configurations based on design doc
56
+ slashOverridePayload?: EthAddress;
57
+ slashPayloadTtlSeconds: number; // TTL for payloads, in seconds
58
+ slashPruneCreate: boolean;
59
+ slashPrunePenalty: bigint;
60
+ slashPruneSignal: boolean;
61
+ slashInactivityCreateTargetPercentage: number; // 0-1, 0.9 means 90%
62
+ slashInactivityCreatePenalty: bigint;
63
+ slashInactivitySignalTargetPercentage: number; // 0-1, 0.6 means 60%
64
+ slashProposerRoundPollingIntervalSeconds: number;
65
+ // Consider adding: slashInactivityCreateEnabled: boolean;
66
+ }
67
+
68
+ export const DefaultSlasherConfig: SlasherConfig = {
69
+ slashInactivityCreatePenalty: 1n,
70
+ slashInactivityCreateTargetPercentage: 0.9,
71
+ slashInactivitySignalTargetPercentage: 0.6,
72
+ slashPayloadTtlSeconds: 60 * 60 * 24, // 1 day
73
+ slashPruneCreate: false,
74
+ slashPrunePenalty: 1n,
75
+ slashPruneSignal: true,
76
+ slashOverridePayload: undefined,
77
+ slashProposerRoundPollingIntervalSeconds: 12,
78
+ };
79
+
80
+ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
81
+ slashOverridePayload: {
82
+ description: 'An Ethereum address for a slash payload to vote for unconditionally.',
83
+ parseEnv: (val: string) => (val ? EthAddress.fromString(val) : undefined),
84
+ defaultValue: DefaultSlasherConfig.slashOverridePayload,
85
+ },
86
+ slashPayloadTtlSeconds: {
87
+ description: 'Time-to-live for slash payloads in seconds.',
88
+ ...numberConfigHelper(DefaultSlasherConfig.slashPayloadTtlSeconds),
89
+ },
90
+ slashPruneCreate: {
91
+ description: 'Enable creation of slash payloads for pruned epochs.',
92
+ ...booleanConfigHelper(DefaultSlasherConfig.slashPruneCreate),
93
+ },
94
+ slashPrunePenalty: {
95
+ description: 'Penalty amount for slashing validators of a pruned epoch.',
96
+ ...bigintConfigHelper(DefaultSlasherConfig.slashPrunePenalty),
97
+ },
98
+ slashPruneSignal: {
99
+ description: 'Enable voting for slash payloads for pruned epochs.',
100
+ ...booleanConfigHelper(DefaultSlasherConfig.slashPruneSignal),
101
+ },
102
+ slashInactivityCreateTargetPercentage: {
103
+ description: 'Missed attestation percentage to trigger creation of inactivity slash payload (0-100).',
104
+ ...numberConfigHelper(DefaultSlasherConfig.slashInactivityCreateTargetPercentage),
105
+ },
106
+ slashInactivityCreatePenalty: {
107
+ description: 'Penalty amount for slashing an inactive validator.',
108
+ ...bigintConfigHelper(DefaultSlasherConfig.slashInactivityCreatePenalty),
109
+ },
110
+ slashInactivitySignalTargetPercentage: {
111
+ description: 'Missed attestation percentage to trigger voting for an inactivity slash payload (0-100).',
112
+ ...numberConfigHelper(DefaultSlasherConfig.slashInactivitySignalTargetPercentage),
113
+ },
114
+ slashProposerRoundPollingIntervalSeconds: {
115
+ description: 'Polling interval for slashing proposer round in seconds.',
116
+ ...numberConfigHelper(DefaultSlasherConfig.slashProposerRoundPollingIntervalSeconds),
117
+ },
118
+ };
@@ -0,0 +1,94 @@
1
+ import { EpochCache } from '@aztec/epoch-cache';
2
+ import { type Logger, createLogger } from '@aztec/foundation/log';
3
+ import { type L2BlockSourceEvent, type L2BlockSourceEventEmitter, L2BlockSourceEvents } from '@aztec/stdlib/block';
4
+
5
+ import EventEmitter from 'node:events';
6
+ import type { Hex } from 'viem';
7
+
8
+ import { Offence, WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from './config.js';
9
+
10
+ export class EpochPruneWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
11
+ private log: Logger = createLogger('epoch-prune-watcher');
12
+
13
+ // Keep track of pruned epochs we've seen to their committee
14
+ private prunedEpochs: Map<bigint, `0x${string}`[]> = new Map();
15
+ // Only keep track of the last N pruned epochs
16
+ private maxPrunedEpochs = 100;
17
+
18
+ constructor(
19
+ private l2BlockSource: L2BlockSourceEventEmitter,
20
+ private epochCache: EpochCache,
21
+ private penalty: bigint,
22
+ ) {
23
+ super();
24
+ this.log.info('EpochPruneWatcher initialized');
25
+ }
26
+
27
+ public start() {
28
+ this.l2BlockSource.on(L2BlockSourceEvents.L2PruneDetected, this.handlePruneL2Blocks.bind(this));
29
+ return Promise.resolve();
30
+ }
31
+
32
+ public stop() {
33
+ this.l2BlockSource.removeListener(L2BlockSourceEvents.L2PruneDetected, this.handlePruneL2Blocks.bind(this));
34
+ return Promise.resolve();
35
+ }
36
+
37
+ // TODO(#14407), TODO(#14408)
38
+ // We should only be slashing due to prune if:
39
+ // - the data was not available (#14407)
40
+ // - OR the data was available and the epoch could have been proven (#14408)
41
+ private handlePruneL2Blocks(event: L2BlockSourceEvent): void {
42
+ const { epochNumber } = event;
43
+ this.log.info(`Detected chain prune. Attempting to create slash for epoch ${epochNumber}`, event);
44
+
45
+ this.getValidatorsForEpoch(epochNumber)
46
+ .then(validators => {
47
+ const args = this.validatorsToSlashingArgs(validators);
48
+
49
+ if (args) {
50
+ this.addToPrunedEpochs(epochNumber, validators);
51
+ this.emit(WANT_TO_SLASH_EVENT, args);
52
+ }
53
+ })
54
+ .catch(error => {
55
+ this.log.error('Error getting validators for epoch', error);
56
+ });
57
+ }
58
+
59
+ private addToPrunedEpochs(epochNumber: bigint, validators: Hex[]) {
60
+ this.prunedEpochs.set(epochNumber, validators);
61
+ if (this.prunedEpochs.size > this.maxPrunedEpochs) {
62
+ this.prunedEpochs.delete(this.prunedEpochs.keys().next().value!);
63
+ }
64
+ }
65
+
66
+ private async getValidatorsForEpoch(epochNumber: bigint): Promise<`0x${string}`[]> {
67
+ const { committee } = await this.epochCache.getCommitteeForEpoch(epochNumber);
68
+ return committee.map(v => v.toString());
69
+ }
70
+
71
+ private validatorsToSlashingArgs(validators: `0x${string}`[]): WantToSlashArgs | undefined {
72
+ if (validators.length === 0) {
73
+ this.log.debug('No validators found for epoch, skipping slash creation.');
74
+ return undefined;
75
+ }
76
+ const amounts = Array(validators.length).fill(this.penalty);
77
+ const offenses = Array(validators.length).fill(Offence.EPOCH_PRUNE);
78
+ return { validators, amounts, offenses };
79
+ }
80
+
81
+ private wantToSlashForEpoch(validator: `0x${string}`, amount: bigint, epochNumber: bigint): boolean {
82
+ return this.prunedEpochs.get(epochNumber)?.includes(validator) ?? false;
83
+ }
84
+
85
+ public shouldSlash(validator: `0x${string}`, amount: bigint, _offense: Offence): Promise<boolean> {
86
+ for (const epoch of this.prunedEpochs.keys()) {
87
+ if (this.wantToSlashForEpoch(validator, amount, epoch)) {
88
+ return Promise.resolve(true);
89
+ }
90
+ }
91
+
92
+ return Promise.resolve(false);
93
+ }
94
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './config.js';
2
+ export * from './epoch_prune_watcher.js';
3
+ export * from './slasher_client.js';