@aztec/slasher 0.0.1-commit.21caa21
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 +218 -0
- package/dest/config.d.ts +6 -0
- package/dest/config.d.ts.map +1 -0
- package/dest/config.js +134 -0
- package/dest/empire_slasher_client.d.ts +190 -0
- package/dest/empire_slasher_client.d.ts.map +1 -0
- package/dest/empire_slasher_client.js +572 -0
- package/dest/factory/create_facade.d.ts +15 -0
- package/dest/factory/create_facade.d.ts.map +1 -0
- package/dest/factory/create_facade.js +23 -0
- package/dest/factory/create_implementation.d.ts +17 -0
- package/dest/factory/create_implementation.d.ts.map +1 -0
- package/dest/factory/create_implementation.js +73 -0
- package/dest/factory/get_settings.d.ts +4 -0
- package/dest/factory/get_settings.d.ts.map +1 -0
- package/dest/factory/get_settings.js +36 -0
- package/dest/factory/index.d.ts +3 -0
- package/dest/factory/index.d.ts.map +1 -0
- package/dest/factory/index.js +2 -0
- package/dest/index.d.ts +11 -0
- package/dest/index.d.ts.map +1 -0
- package/dest/index.js +10 -0
- package/dest/null_slasher_client.d.ts +17 -0
- package/dest/null_slasher_client.d.ts.map +1 -0
- package/dest/null_slasher_client.js +33 -0
- package/dest/slash_offenses_collector.d.ts +45 -0
- package/dest/slash_offenses_collector.d.ts.map +1 -0
- package/dest/slash_offenses_collector.js +94 -0
- package/dest/slash_round_monitor.d.ts +30 -0
- package/dest/slash_round_monitor.d.ts.map +1 -0
- package/dest/slash_round_monitor.js +52 -0
- package/dest/slasher_client_facade.d.ts +44 -0
- package/dest/slasher_client_facade.d.ts.map +1 -0
- package/dest/slasher_client_facade.js +76 -0
- package/dest/slasher_client_interface.d.ts +39 -0
- package/dest/slasher_client_interface.d.ts.map +1 -0
- package/dest/slasher_client_interface.js +4 -0
- package/dest/stores/offenses_store.d.ts +37 -0
- package/dest/stores/offenses_store.d.ts.map +1 -0
- package/dest/stores/offenses_store.js +105 -0
- package/dest/stores/payloads_store.d.ts +29 -0
- package/dest/stores/payloads_store.d.ts.map +1 -0
- package/dest/stores/payloads_store.js +125 -0
- package/dest/stores/schema_version.d.ts +2 -0
- package/dest/stores/schema_version.d.ts.map +1 -0
- package/dest/stores/schema_version.js +1 -0
- package/dest/tally_slasher_client.d.ts +125 -0
- package/dest/tally_slasher_client.d.ts.map +1 -0
- package/dest/tally_slasher_client.js +349 -0
- package/dest/test/dummy_watcher.d.ts +11 -0
- package/dest/test/dummy_watcher.d.ts.map +1 -0
- package/dest/test/dummy_watcher.js +14 -0
- package/dest/watcher.d.ts +21 -0
- package/dest/watcher.d.ts.map +1 -0
- package/dest/watcher.js +1 -0
- package/dest/watchers/attestations_block_watcher.d.ts +33 -0
- package/dest/watchers/attestations_block_watcher.d.ts.map +1 -0
- package/dest/watchers/attestations_block_watcher.js +136 -0
- package/dest/watchers/epoch_prune_watcher.d.ts +37 -0
- package/dest/watchers/epoch_prune_watcher.d.ts.map +1 -0
- package/dest/watchers/epoch_prune_watcher.js +135 -0
- package/package.json +90 -0
- package/src/config.ts +157 -0
- package/src/empire_slasher_client.ts +657 -0
- package/src/factory/create_facade.ts +52 -0
- package/src/factory/create_implementation.ts +159 -0
- package/src/factory/get_settings.ts +58 -0
- package/src/factory/index.ts +2 -0
- package/src/index.ts +10 -0
- package/src/null_slasher_client.ts +41 -0
- package/src/slash_offenses_collector.ts +118 -0
- package/src/slash_round_monitor.ts +62 -0
- package/src/slasher_client_facade.ts +101 -0
- package/src/slasher_client_interface.ts +46 -0
- package/src/stores/offenses_store.ts +145 -0
- package/src/stores/payloads_store.ts +146 -0
- package/src/stores/schema_version.ts +1 -0
- package/src/tally_slasher_client.ts +442 -0
- package/src/test/dummy_watcher.ts +21 -0
- package/src/watcher.ts +27 -0
- package/src/watchers/attestations_block_watcher.ts +181 -0
- package/src/watchers/epoch_prune_watcher.ts +193 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { EthAddress } from '@aztec/aztec.js/addresses';
|
|
2
|
+
import type { EpochCache } from '@aztec/epoch-cache';
|
|
3
|
+
import { RollupContract, SlasherContract, TallySlashingProposerContract } from '@aztec/ethereum/contracts';
|
|
4
|
+
import { maxBigint } from '@aztec/foundation/bigint';
|
|
5
|
+
import { SlotNumber } from '@aztec/foundation/branded-types';
|
|
6
|
+
import { compactArray, partition, times } from '@aztec/foundation/collection';
|
|
7
|
+
import { createLogger } from '@aztec/foundation/log';
|
|
8
|
+
import { sleep } from '@aztec/foundation/sleep';
|
|
9
|
+
import type { DateProvider } from '@aztec/foundation/timer';
|
|
10
|
+
import type { Prettify } from '@aztec/foundation/types';
|
|
11
|
+
import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
|
|
12
|
+
import {
|
|
13
|
+
type Offense,
|
|
14
|
+
OffenseType,
|
|
15
|
+
type ProposerSlashAction,
|
|
16
|
+
type ProposerSlashActionProvider,
|
|
17
|
+
type SlashPayloadRound,
|
|
18
|
+
getEpochsForRound,
|
|
19
|
+
getSlashConsensusVotesFromOffenses,
|
|
20
|
+
} from '@aztec/stdlib/slashing';
|
|
21
|
+
|
|
22
|
+
import type { Hex } from 'viem';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
SlashOffensesCollector,
|
|
26
|
+
type SlashOffensesCollectorConfig,
|
|
27
|
+
type SlashOffensesCollectorSettings,
|
|
28
|
+
} from './slash_offenses_collector.js';
|
|
29
|
+
import { SlashRoundMonitor, type SlashRoundMonitorSettings } from './slash_round_monitor.js';
|
|
30
|
+
import type { SlasherClientInterface } from './slasher_client_interface.js';
|
|
31
|
+
import type { SlasherOffensesStore } from './stores/offenses_store.js';
|
|
32
|
+
import type { Watcher } from './watcher.js';
|
|
33
|
+
|
|
34
|
+
/** Settings used in the tally slasher client, loaded from the L1 contracts during initialization */
|
|
35
|
+
export type TallySlasherSettings = Prettify<
|
|
36
|
+
SlashRoundMonitorSettings &
|
|
37
|
+
SlashOffensesCollectorSettings & {
|
|
38
|
+
slashingLifetimeInRounds: number;
|
|
39
|
+
slashingExecutionDelayInRounds: number;
|
|
40
|
+
slashingRoundSizeInEpochs: number;
|
|
41
|
+
slashingOffsetInRounds: number;
|
|
42
|
+
slashingQuorumSize: number;
|
|
43
|
+
slashingAmounts: [bigint, bigint, bigint];
|
|
44
|
+
/** Committee size for block proposal */
|
|
45
|
+
targetCommitteeSize: number;
|
|
46
|
+
}
|
|
47
|
+
>;
|
|
48
|
+
|
|
49
|
+
export type TallySlasherClientConfig = SlashOffensesCollectorConfig &
|
|
50
|
+
Pick<SlasherConfig, 'slashValidatorsAlways' | 'slashValidatorsNever' | 'slashExecuteRoundsLookBack'>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The Tally Slasher client is responsible for managing slashable offenses using
|
|
54
|
+
* the consensus-based slashing model where proposers vote on individual validator offenses.
|
|
55
|
+
*
|
|
56
|
+
* The client subscribes to several slash watchers that emit offenses and tracks them. When the slasher is the
|
|
57
|
+
* proposer, it votes for which validators from past epochs should be slashed based on collected offenses.
|
|
58
|
+
* Voting is handled by the sequencer publisher, the slasher client does not interact with L1 directly.
|
|
59
|
+
* The client also monitors rounds and executes slashing when rounds become executable after reaching quorum.
|
|
60
|
+
*
|
|
61
|
+
* Voting and offense collection
|
|
62
|
+
* - Time is divided into rounds (ROUND_SIZE slots each). During each round, block proposers can submit votes
|
|
63
|
+
* indicating which validators from SLASH_OFFSET_IN_ROUNDS rounds ago should be slashed.
|
|
64
|
+
* - Votes are encoded as bytes where each validator's vote is represented by 2 bits indicating the slash amount (0-3 slash units)
|
|
65
|
+
* for the validator in the committee being slashed.
|
|
66
|
+
* - When gathering offenses for round N, the system looks at offenses from round N-2 (where 2 is the hardcoded
|
|
67
|
+
* offset), giving time to detect offenses and vote on them in a later round.
|
|
68
|
+
* - Each offense carries an epoch or block identifier to differentiate multiple offenses by the same validator.
|
|
69
|
+
*
|
|
70
|
+
* Quorum and execution
|
|
71
|
+
* - After a round ends, there is an execution delay period for review so the VETOER in the Slasher can veto
|
|
72
|
+
* if needed.
|
|
73
|
+
* - Once the delay passes, anyone can call executeRound() to tally votes and execute slashing.
|
|
74
|
+
* - Validators that reach the quorum threshold are slashed. A vote for slashing N units is also considered
|
|
75
|
+
* a vote for slashing N-1, N-2, ..., 1 units. The system slashes for the largest amount that reaches quorum.
|
|
76
|
+
* - The client monitors executable rounds and triggers execution when appropriate.
|
|
77
|
+
*
|
|
78
|
+
* Differences from Empire model
|
|
79
|
+
* - No fixed slash payloads - votes are for individual validator offenses encoded in bytes
|
|
80
|
+
* - The L1 contract determines which offenses reach quorum rather than nodes agreeing on a payload
|
|
81
|
+
* - Proposers vote directly on which validators to slash and by how much
|
|
82
|
+
* - Uses a slash offset to vote on validators from past rounds (e.g., round N votes on round N-2)
|
|
83
|
+
*/
|
|
84
|
+
export class TallySlasherClient implements ProposerSlashActionProvider, SlasherClientInterface {
|
|
85
|
+
protected unwatchCallbacks: (() => void)[] = [];
|
|
86
|
+
protected roundMonitor: SlashRoundMonitor;
|
|
87
|
+
protected offensesCollector: SlashOffensesCollector;
|
|
88
|
+
|
|
89
|
+
constructor(
|
|
90
|
+
private config: TallySlasherClientConfig,
|
|
91
|
+
private settings: TallySlasherSettings,
|
|
92
|
+
private tallySlashingProposer: TallySlashingProposerContract,
|
|
93
|
+
private slasher: SlasherContract,
|
|
94
|
+
private rollup: RollupContract,
|
|
95
|
+
watchers: Watcher[],
|
|
96
|
+
private epochCache: EpochCache,
|
|
97
|
+
private dateProvider: DateProvider,
|
|
98
|
+
private offensesStore: SlasherOffensesStore,
|
|
99
|
+
private log = createLogger('slasher:consensus'),
|
|
100
|
+
) {
|
|
101
|
+
this.roundMonitor = new SlashRoundMonitor(settings, dateProvider);
|
|
102
|
+
this.offensesCollector = new SlashOffensesCollector(config, settings, watchers, offensesStore);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public async start() {
|
|
106
|
+
this.log.debug('Starting Tally Slasher client...');
|
|
107
|
+
|
|
108
|
+
this.roundMonitor.start();
|
|
109
|
+
await this.offensesCollector.start();
|
|
110
|
+
|
|
111
|
+
// Listen for RoundExecuted events
|
|
112
|
+
this.unwatchCallbacks.push(
|
|
113
|
+
this.tallySlashingProposer.listenToRoundExecuted(
|
|
114
|
+
({ round, slashCount, l1BlockHash }) =>
|
|
115
|
+
void this.handleRoundExecuted(round, slashCount, l1BlockHash).catch(err =>
|
|
116
|
+
this.log.error('Error handling round executed', err),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Check for round changes
|
|
122
|
+
this.unwatchCallbacks.push(this.roundMonitor.listenToNewRound(round => this.handleNewRound(round)));
|
|
123
|
+
|
|
124
|
+
this.log.info(`Started tally slasher client`);
|
|
125
|
+
return Promise.resolve();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Stop the tally slasher client
|
|
130
|
+
*/
|
|
131
|
+
public async stop() {
|
|
132
|
+
this.log.debug('Stopping Tally Slasher client...');
|
|
133
|
+
|
|
134
|
+
for (const unwatchCallback of this.unwatchCallbacks) {
|
|
135
|
+
unwatchCallback();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.roundMonitor.stop();
|
|
139
|
+
await this.offensesCollector.stop();
|
|
140
|
+
|
|
141
|
+
// Sleeping to sidestep viem issue with unwatching events
|
|
142
|
+
await sleep(2000);
|
|
143
|
+
this.log.info('Tally Slasher client stopped');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Returns the current config */
|
|
147
|
+
public getConfig(): SlasherConfig {
|
|
148
|
+
return this.config as SlasherConfig;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Update the config of the slasher client */
|
|
152
|
+
public updateConfig(config: Partial<SlasherConfig>) {
|
|
153
|
+
this.config = { ...this.config, ...config };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Triggered on a time basis when we enter a new slashing round. Clears expired offenses. */
|
|
157
|
+
protected async handleNewRound(round: bigint) {
|
|
158
|
+
this.log.info(`Starting new tally slashing round ${round}`);
|
|
159
|
+
await this.offensesCollector.handleNewRound(round);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Called when we see a RoundExecuted event on the TallySlashingProposer (just for logging). */
|
|
163
|
+
protected async handleRoundExecuted(round: bigint, slashCount: bigint, l1BlockHash: Hex) {
|
|
164
|
+
const slashes = await this.rollup.getSlashEvents(l1BlockHash);
|
|
165
|
+
this.log.info(`Slashing round ${round} has been executed with ${slashCount} slashes`, { slashes });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get the actions the proposer should take for slashing
|
|
170
|
+
* @param slotNumber - The current slot number
|
|
171
|
+
* @returns The actions to take
|
|
172
|
+
*/
|
|
173
|
+
public async getProposerActions(slotNumber: SlotNumber): Promise<ProposerSlashAction[]> {
|
|
174
|
+
const [executeAction, voteAction] = await Promise.all([
|
|
175
|
+
this.getExecuteSlashAction(slotNumber),
|
|
176
|
+
this.getVoteOffensesAction(slotNumber),
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
return compactArray<ProposerSlashAction>([executeAction, voteAction]);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Returns an execute slash action if there are any rounds ready to be executed.
|
|
184
|
+
* Returns the oldest slash action if there are multiple rounds pending execution.
|
|
185
|
+
*/
|
|
186
|
+
protected async getExecuteSlashAction(slotNumber: SlotNumber): Promise<ProposerSlashAction | undefined> {
|
|
187
|
+
const { round: currentRound } = this.roundMonitor.getRoundForSlot(slotNumber);
|
|
188
|
+
const slashingExecutionDelayInRounds = BigInt(this.settings.slashingExecutionDelayInRounds);
|
|
189
|
+
const executableRound = currentRound - slashingExecutionDelayInRounds - 1n;
|
|
190
|
+
const lookBack = BigInt(this.config.slashExecuteRoundsLookBack);
|
|
191
|
+
const slashingLifetimeInRounds = BigInt(this.settings.slashingLifetimeInRounds);
|
|
192
|
+
|
|
193
|
+
// Compute the oldest executable round considering both lookBack and lifetimeInRounds
|
|
194
|
+
// A round is only executable if currentRound <= round + lifetimeInRounds
|
|
195
|
+
// So the oldest round we can execute is: currentRound - lifetimeInRounds
|
|
196
|
+
const oldestByLifetime = maxBigint(0n, currentRound - slashingLifetimeInRounds);
|
|
197
|
+
const oldestByLookBack = maxBigint(0n, executableRound - lookBack);
|
|
198
|
+
const oldestExecutableRound = maxBigint(oldestByLifetime, oldestByLookBack);
|
|
199
|
+
|
|
200
|
+
// Check if slashing is enabled at all
|
|
201
|
+
if (!(await this.slasher.isSlashingEnabled())) {
|
|
202
|
+
this.log.warn(`Slashing is disabled in the Slasher contract (skipping execution)`);
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.log.debug(`Checking slashing rounds ${oldestExecutableRound} to ${executableRound} to execute`, {
|
|
207
|
+
slotNumber,
|
|
208
|
+
currentRound,
|
|
209
|
+
oldestExecutableRound,
|
|
210
|
+
oldestByLifetime,
|
|
211
|
+
oldestByLookBack,
|
|
212
|
+
executableRound,
|
|
213
|
+
slashingExecutionDelayInRounds,
|
|
214
|
+
lookBack,
|
|
215
|
+
slashingLifetimeInRounds,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Iterate over all rounds, starting from the oldest, until we find one that is executable
|
|
219
|
+
for (let roundToCheck = oldestExecutableRound; roundToCheck <= executableRound; roundToCheck++) {
|
|
220
|
+
const action = await this.tryGetRoundExecuteAction(roundToCheck, slotNumber);
|
|
221
|
+
if (action) {
|
|
222
|
+
return action;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// And return nothing if none are found
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Checks if a given round is executable and returns an execute-slash action for it if so.
|
|
232
|
+
* Assumes round number has already been checked against lifetime and execution delay.
|
|
233
|
+
* @param executableRound - The round to check for execution
|
|
234
|
+
*/
|
|
235
|
+
private async tryGetRoundExecuteAction(
|
|
236
|
+
executableRound: bigint,
|
|
237
|
+
slotNumber: SlotNumber,
|
|
238
|
+
): Promise<ProposerSlashAction | undefined> {
|
|
239
|
+
let logData: Record<string, unknown> = { executableRound, slotNumber };
|
|
240
|
+
this.log.debug(`Testing if slashing round ${executableRound} is executable`, logData);
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const roundInfo = await this.tallySlashingProposer.getRound(executableRound);
|
|
244
|
+
logData = { ...logData, roundInfo };
|
|
245
|
+
if (roundInfo.isExecuted) {
|
|
246
|
+
this.log.verbose(`Round ${executableRound} has already been executed`, logData);
|
|
247
|
+
return undefined;
|
|
248
|
+
} else if (roundInfo.voteCount === 0n) {
|
|
249
|
+
this.log.debug(`Round ${executableRound} received no votes`, logData);
|
|
250
|
+
return undefined;
|
|
251
|
+
} else if (roundInfo.voteCount < this.settings.slashingQuorumSize) {
|
|
252
|
+
this.log.verbose(`Round ${executableRound} does not have enough votes to execute`, logData);
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check if round is ready to execute at the given slot
|
|
257
|
+
const isReadyToExecute = await this.tallySlashingProposer.isRoundReadyToExecute(executableRound, slotNumber);
|
|
258
|
+
if (!isReadyToExecute) {
|
|
259
|
+
this.log.warn(
|
|
260
|
+
`Round ${executableRound} is not ready to execute at slot ${slotNumber} according to contract check`,
|
|
261
|
+
logData,
|
|
262
|
+
);
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check if the round yields any slashing at all
|
|
267
|
+
const { actions: slashActions, committees } = await this.tallySlashingProposer.getTally(executableRound);
|
|
268
|
+
if (slashActions.length === 0) {
|
|
269
|
+
this.log.verbose(`Round ${executableRound} does not resolve in any slashing`, logData);
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Check if the slash payload is vetoed
|
|
274
|
+
const payload = await this.tallySlashingProposer.getPayload(executableRound);
|
|
275
|
+
const isVetoed = await this.slasher.isPayloadVetoed(payload.address);
|
|
276
|
+
if (isVetoed) {
|
|
277
|
+
this.log.warn(`Round ${executableRound} payload is vetoed (skipping execution)`, {
|
|
278
|
+
payloadAddress: payload.address.toString(),
|
|
279
|
+
...logData,
|
|
280
|
+
});
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
this.log.info(`Round ${executableRound} is ready to execute with ${slashActions.length} slashes`, {
|
|
285
|
+
slashActions,
|
|
286
|
+
payloadAddress: payload.address.toString(),
|
|
287
|
+
...logData,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// We only need to post committees that are actually slashed
|
|
291
|
+
const slashedCommittees = committees.map(c =>
|
|
292
|
+
c.some(validator => slashActions.some(action => action.validator.equals(validator))) ? c : [],
|
|
293
|
+
);
|
|
294
|
+
this.log.debug(`Collected ${committees.length} committees for executing round ${executableRound}`, {
|
|
295
|
+
slashedCommittees,
|
|
296
|
+
...logData,
|
|
297
|
+
});
|
|
298
|
+
return { type: 'execute-slash', round: executableRound, committees: slashedCommittees };
|
|
299
|
+
} catch (error) {
|
|
300
|
+
this.log.error(`Error checking round to execute ${executableRound}`, error);
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Returns a vote action based on offenses from the target round (with offset applied) */
|
|
306
|
+
protected async getVoteOffensesAction(slotNumber: SlotNumber): Promise<ProposerSlashAction | undefined> {
|
|
307
|
+
// Compute what round we are in based on the slot number and what round will be slashed
|
|
308
|
+
const { round: currentRound } = this.roundMonitor.getRoundForSlot(slotNumber);
|
|
309
|
+
const slashedRound = this.getSlashedRound(currentRound);
|
|
310
|
+
if (slashedRound < 0n) {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Compute offenses to slash, by loading the offenses for this round, adding synthetic offenses
|
|
315
|
+
// for validators that should always be slashed, and removing the ones that should never be slashed.
|
|
316
|
+
const offensesForRound = await this.gatherOffensesForRound(currentRound);
|
|
317
|
+
const offensesFromAlwaysSlash = (this.config.slashValidatorsAlways ?? []).map(validator => ({
|
|
318
|
+
validator,
|
|
319
|
+
amount: this.settings.slashingAmounts[2],
|
|
320
|
+
offenseType: OffenseType.UNKNOWN,
|
|
321
|
+
}));
|
|
322
|
+
const [offensesToForgive, offensesToSlash] = partition([...offensesForRound, ...offensesFromAlwaysSlash], offense =>
|
|
323
|
+
this.config.slashValidatorsNever?.some(v => v.equals(offense.validator)),
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
if (offensesFromAlwaysSlash.length > 0) {
|
|
327
|
+
this.log.verbose(`Slashing ${offensesFromAlwaysSlash.length} validators due to always-slash config`, {
|
|
328
|
+
slotNumber,
|
|
329
|
+
currentRound,
|
|
330
|
+
slashedRound,
|
|
331
|
+
offensesToForgive,
|
|
332
|
+
slashValidatorsAlways: this.config.slashValidatorsAlways,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (offensesToForgive.length > 0) {
|
|
337
|
+
this.log.verbose(`Skipping slashing of ${offensesToForgive.length} offenses`, {
|
|
338
|
+
slotNumber,
|
|
339
|
+
currentRound,
|
|
340
|
+
slashedRound,
|
|
341
|
+
offensesToForgive,
|
|
342
|
+
slashValidatorsNever: this.config.slashValidatorsNever,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (offensesToSlash.length === 0) {
|
|
347
|
+
this.log.debug(`No offenses to slash for round ${slashedRound}`, { currentRound, slotNumber, slashedRound });
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
this.log.info(`Voting to slash ${offensesToSlash.length} offenses`, {
|
|
352
|
+
slotNumber,
|
|
353
|
+
currentRound,
|
|
354
|
+
slashedRound,
|
|
355
|
+
offensesToSlash,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const committees = await this.collectCommitteesActiveDuringRound(slashedRound);
|
|
359
|
+
const epochsForCommittees = getEpochsForRound(slashedRound, this.settings);
|
|
360
|
+
const votes = getSlashConsensusVotesFromOffenses(
|
|
361
|
+
offensesToSlash,
|
|
362
|
+
committees,
|
|
363
|
+
epochsForCommittees.map(e => BigInt(e)),
|
|
364
|
+
this.settings,
|
|
365
|
+
);
|
|
366
|
+
if (votes.every(v => v === 0)) {
|
|
367
|
+
this.log.warn(`Computed votes for offenses are all zero. Skipping vote.`, {
|
|
368
|
+
slotNumber,
|
|
369
|
+
currentRound,
|
|
370
|
+
slashedRound,
|
|
371
|
+
offensesToSlash,
|
|
372
|
+
committees,
|
|
373
|
+
});
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.log.debug(`Computed votes for slashing ${offensesToSlash.length} offenses`, {
|
|
378
|
+
slashedRound,
|
|
379
|
+
currentRound,
|
|
380
|
+
votes,
|
|
381
|
+
committees,
|
|
382
|
+
settings: this.settings,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
type: 'vote-offenses',
|
|
387
|
+
round: currentRound,
|
|
388
|
+
votes,
|
|
389
|
+
committees,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Returns the committees that were active during the timespan of a given round */
|
|
394
|
+
private collectCommitteesActiveDuringRound(round: bigint): Promise<EthAddress[][]> {
|
|
395
|
+
const epochsToSlash = getEpochsForRound(round, this.settings);
|
|
396
|
+
const emptyCommittee = times(Number(this.settings.targetCommitteeSize), () => EthAddress.ZERO);
|
|
397
|
+
return Promise.all(
|
|
398
|
+
epochsToSlash.map(epoch => this.epochCache.getCommitteeForEpoch(epoch).then(c => c.committee ?? emptyCommittee)),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Get slash payloads is NOT SUPPORTED in tally model
|
|
404
|
+
* @throws Error indicating this operation is not supported
|
|
405
|
+
*/
|
|
406
|
+
public getSlashPayloads(): Promise<SlashPayloadRound[]> {
|
|
407
|
+
return Promise.reject(new Error('Tally slashing model does not support slash payloads'));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Gather offenses to be slashed on a given round.
|
|
412
|
+
* In tally slashing, round N slashes validators from round N - slashOffsetInRounds.
|
|
413
|
+
* @param round - The round to get offenses for, defaults to current round
|
|
414
|
+
* @returns Array of pending offenses for the round with offset applied
|
|
415
|
+
*/
|
|
416
|
+
public async gatherOffensesForRound(round?: bigint): Promise<Offense[]> {
|
|
417
|
+
const targetRound = this.getSlashedRound(round);
|
|
418
|
+
if (targetRound < 0n) {
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return await this.offensesStore.getOffensesForRound(targetRound);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Returns all pending offenses stored */
|
|
426
|
+
public getPendingOffenses(): Promise<Offense[]> {
|
|
427
|
+
return this.offensesStore.getPendingOffenses();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Returns the round to be slashed given the current round by applying the slash offset.
|
|
432
|
+
* During round N, we cannot slash the validators from the epochs of the same round, since the round is not over,
|
|
433
|
+
* and besides we would be asking the current validators to vote to slash themselves. So during round N we look at the
|
|
434
|
+
* epochs spanned during round N - SLASH_OFFSET_IN_ROUNDS. This offset means that the epochs we slash are complete,
|
|
435
|
+
* and also gives nodes time to detect any misbehavior (eg slashing for prunes requires the proof submission window to
|
|
436
|
+
* pass).
|
|
437
|
+
*/
|
|
438
|
+
private getSlashedRound(round?: bigint) {
|
|
439
|
+
round ??= this.roundMonitor.getCurrentRound().round;
|
|
440
|
+
return round - BigInt(this.settings.slashingOffsetInRounds);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
|
|
2
|
+
|
|
3
|
+
import EventEmitter from 'node:events';
|
|
4
|
+
|
|
5
|
+
import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js';
|
|
6
|
+
|
|
7
|
+
export class DummyWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
|
|
8
|
+
public updateConfig(_config: Partial<SlasherConfig>) {}
|
|
9
|
+
|
|
10
|
+
public start() {
|
|
11
|
+
return Promise.resolve();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public stop() {
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public triggerSlash(args: WantToSlashArgs[]) {
|
|
19
|
+
this.emit(WANT_TO_SLASH_EVENT, args);
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/watcher.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { EthAddress } from '@aztec/foundation/eth-address';
|
|
2
|
+
import type { TypedEventEmitter } from '@aztec/foundation/types';
|
|
3
|
+
import { OffenseType } from '@aztec/stdlib/slashing';
|
|
4
|
+
|
|
5
|
+
import type { SlasherConfig } from './config.js';
|
|
6
|
+
|
|
7
|
+
export const WANT_TO_SLASH_EVENT = 'want-to-slash' as const;
|
|
8
|
+
|
|
9
|
+
export interface WantToSlashArgs {
|
|
10
|
+
validator: EthAddress;
|
|
11
|
+
amount: bigint;
|
|
12
|
+
offenseType: OffenseType;
|
|
13
|
+
epochOrSlot: bigint; // Epoch number for epoch-based offenses, block number for block-based
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Event map for specific, known events of a watcher
|
|
17
|
+
export interface WatcherEventMap {
|
|
18
|
+
[WANT_TO_SLASH_EVENT]: (args: WantToSlashArgs[]) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type WatcherEmitter = TypedEventEmitter<WatcherEventMap>;
|
|
22
|
+
|
|
23
|
+
export type Watcher = WatcherEmitter & {
|
|
24
|
+
start?: () => Promise<void>;
|
|
25
|
+
stop?: () => Promise<void>;
|
|
26
|
+
updateConfig: (config: Partial<SlasherConfig>) => void;
|
|
27
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { EpochCache } from '@aztec/epoch-cache';
|
|
2
|
+
import { SlotNumber } from '@aztec/foundation/branded-types';
|
|
3
|
+
import { merge, pick } from '@aztec/foundation/collection';
|
|
4
|
+
import { type Logger, createLogger } from '@aztec/foundation/log';
|
|
5
|
+
import {
|
|
6
|
+
type InvalidBlockDetectedEvent,
|
|
7
|
+
type L2BlockInfo,
|
|
8
|
+
type L2BlockSourceEventEmitter,
|
|
9
|
+
L2BlockSourceEvents,
|
|
10
|
+
type ValidateBlockNegativeResult,
|
|
11
|
+
} from '@aztec/stdlib/block';
|
|
12
|
+
import { OffenseType } from '@aztec/stdlib/slashing';
|
|
13
|
+
|
|
14
|
+
import EventEmitter from 'node:events';
|
|
15
|
+
|
|
16
|
+
import type { SlasherConfig } from '../config.js';
|
|
17
|
+
import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js';
|
|
18
|
+
|
|
19
|
+
const AttestationsBlockWatcherConfigKeys = [
|
|
20
|
+
'slashAttestDescendantOfInvalidPenalty',
|
|
21
|
+
'slashProposeInvalidAttestationsPenalty',
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
type AttestationsBlockWatcherConfig = Pick<SlasherConfig, (typeof AttestationsBlockWatcherConfigKeys)[number]>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* This watcher is responsible for detecting invalid blocks and creating slashing arguments for offenders.
|
|
28
|
+
* An invalid block is one that doesn't have enough attestations or has incorrect attestations.
|
|
29
|
+
* The proposer of an invalid block should be slashed.
|
|
30
|
+
* If there's another block consecutive to the invalid one, its proposer and attestors should also be slashed.
|
|
31
|
+
*/
|
|
32
|
+
export class AttestationsBlockWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
|
|
33
|
+
private log: Logger = createLogger('attestations-block-watcher');
|
|
34
|
+
|
|
35
|
+
// Only keep track of the last N invalid blocks
|
|
36
|
+
private maxInvalidBlocks = 100;
|
|
37
|
+
|
|
38
|
+
// All invalid archive roots seen
|
|
39
|
+
private invalidArchiveRoots: Set<string> = new Set();
|
|
40
|
+
|
|
41
|
+
private config: AttestationsBlockWatcherConfig;
|
|
42
|
+
|
|
43
|
+
private boundHandleInvalidBlock = (event: InvalidBlockDetectedEvent) => {
|
|
44
|
+
try {
|
|
45
|
+
this.handleInvalidBlock(event);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
this.log.error('Error handling invalid block', err, {
|
|
48
|
+
...event.validationResult,
|
|
49
|
+
reason: event.validationResult.reason,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private l2BlockSource: L2BlockSourceEventEmitter,
|
|
56
|
+
private epochCache: EpochCache,
|
|
57
|
+
config: AttestationsBlockWatcherConfig,
|
|
58
|
+
) {
|
|
59
|
+
super();
|
|
60
|
+
this.config = pick(config, ...AttestationsBlockWatcherConfigKeys);
|
|
61
|
+
this.log.info('AttestationsBlockWatcher initialized');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public updateConfig(newConfig: Partial<AttestationsBlockWatcherConfig>) {
|
|
65
|
+
this.config = merge(this.config, pick(newConfig, ...AttestationsBlockWatcherConfigKeys));
|
|
66
|
+
this.log.verbose('AttestationsBlockWatcher config updated', this.config);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public start() {
|
|
70
|
+
this.l2BlockSource.on(L2BlockSourceEvents.InvalidAttestationsBlockDetected, this.boundHandleInvalidBlock);
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public stop() {
|
|
75
|
+
this.l2BlockSource.removeListener(
|
|
76
|
+
L2BlockSourceEvents.InvalidAttestationsBlockDetected,
|
|
77
|
+
this.boundHandleInvalidBlock,
|
|
78
|
+
);
|
|
79
|
+
return Promise.resolve();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private handleInvalidBlock(event: InvalidBlockDetectedEvent): void {
|
|
83
|
+
const { validationResult } = event;
|
|
84
|
+
const block = validationResult.block;
|
|
85
|
+
|
|
86
|
+
// Check if we already have processed this block, archiver may emit the same event multiple times
|
|
87
|
+
if (this.invalidArchiveRoots.has(block.archive.toString())) {
|
|
88
|
+
this.log.trace(`Already processed invalid block ${block.blockNumber}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.log.verbose(`Detected invalid block ${block.blockNumber}`, {
|
|
93
|
+
...block,
|
|
94
|
+
reason: validationResult.valid === false ? validationResult.reason : 'unknown',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Store the invalid block
|
|
98
|
+
this.addInvalidBlock(event.validationResult.block);
|
|
99
|
+
|
|
100
|
+
// Slash the proposer of the invalid block
|
|
101
|
+
this.slashProposer(event.validationResult);
|
|
102
|
+
|
|
103
|
+
// Check if the parent of this block is invalid as well, if so, we will slash its attestors as well
|
|
104
|
+
this.slashAttestorsOnAncestorInvalid(event.validationResult);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private slashAttestorsOnAncestorInvalid(validationResult: ValidateBlockNegativeResult) {
|
|
108
|
+
const block = validationResult.block;
|
|
109
|
+
|
|
110
|
+
const parentArchive = block.lastArchive.toString();
|
|
111
|
+
if (this.invalidArchiveRoots.has(parentArchive)) {
|
|
112
|
+
const attestors = validationResult.attestors;
|
|
113
|
+
this.log.info(`Want to slash attestors of block ${block.blockNumber} built on invalid block`, {
|
|
114
|
+
...block,
|
|
115
|
+
...attestors,
|
|
116
|
+
parentArchive,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
this.emit(
|
|
120
|
+
WANT_TO_SLASH_EVENT,
|
|
121
|
+
attestors.map(attestor => ({
|
|
122
|
+
validator: attestor,
|
|
123
|
+
amount: this.config.slashAttestDescendantOfInvalidPenalty,
|
|
124
|
+
offenseType: OffenseType.ATTESTED_DESCENDANT_OF_INVALID,
|
|
125
|
+
epochOrSlot: BigInt(SlotNumber(block.slotNumber)),
|
|
126
|
+
})),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private slashProposer(validationResult: ValidateBlockNegativeResult) {
|
|
132
|
+
const { reason, block } = validationResult;
|
|
133
|
+
const blockNumber = block.blockNumber;
|
|
134
|
+
const slot = SlotNumber(block.slotNumber);
|
|
135
|
+
const proposer = this.epochCache.getProposerFromEpochCommittee(validationResult, slot);
|
|
136
|
+
|
|
137
|
+
if (!proposer) {
|
|
138
|
+
this.log.warn(`No proposer found for block ${blockNumber} at slot ${slot}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const offense = this.getOffenseFromInvalidationReason(reason);
|
|
143
|
+
const amount = this.config.slashProposeInvalidAttestationsPenalty;
|
|
144
|
+
const args: WantToSlashArgs = {
|
|
145
|
+
validator: proposer,
|
|
146
|
+
amount,
|
|
147
|
+
offenseType: offense,
|
|
148
|
+
epochOrSlot: BigInt(slot),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
this.log.info(`Want to slash proposer of block ${blockNumber} due to ${reason}`, {
|
|
152
|
+
...block,
|
|
153
|
+
...args,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.emit(WANT_TO_SLASH_EVENT, [args]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private getOffenseFromInvalidationReason(reason: ValidateBlockNegativeResult['reason']): OffenseType {
|
|
160
|
+
switch (reason) {
|
|
161
|
+
case 'invalid-attestation':
|
|
162
|
+
return OffenseType.PROPOSED_INCORRECT_ATTESTATIONS;
|
|
163
|
+
case 'insufficient-attestations':
|
|
164
|
+
return OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS;
|
|
165
|
+
default: {
|
|
166
|
+
const _: never = reason;
|
|
167
|
+
return OffenseType.UNKNOWN;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private addInvalidBlock(block: L2BlockInfo) {
|
|
173
|
+
this.invalidArchiveRoots.add(block.archive.toString());
|
|
174
|
+
|
|
175
|
+
// Prune old entries if we exceed the maximum
|
|
176
|
+
if (this.invalidArchiveRoots.size > this.maxInvalidBlocks) {
|
|
177
|
+
const oldestKey = this.invalidArchiveRoots.keys().next().value!;
|
|
178
|
+
this.invalidArchiveRoots.delete(oldestKey);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|