@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,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