@aztec/slasher 1.2.0 → 2.0.0-nightly.20250813
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/dest/attestations_block_watcher.d.ts +34 -0
- package/dest/attestations_block_watcher.d.ts.map +1 -0
- package/dest/attestations_block_watcher.js +170 -0
- package/dest/config.d.ts +3 -25
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +32 -33
- package/dest/epoch_prune_watcher.d.ts +4 -3
- package/dest/epoch_prune_watcher.d.ts.map +1 -1
- package/dest/epoch_prune_watcher.js +15 -9
- package/dest/index.d.ts +2 -0
- package/dest/index.d.ts.map +1 -1
- package/dest/index.js +2 -0
- package/dest/slasher_client.d.ts +17 -16
- package/dest/slasher_client.d.ts.map +1 -1
- package/dest/slasher_client.js +66 -35
- package/package.json +8 -8
- package/src/attestations_block_watcher.ts +219 -0
- package/src/config.ts +35 -51
- package/src/epoch_prune_watcher.ts +19 -12
- package/src/index.ts +2 -0
- package/src/slasher_client.ts +93 -48
package/dest/slasher_client.js
CHANGED
|
@@ -3,8 +3,9 @@ import { EthAddress } from '@aztec/foundation/eth-address';
|
|
|
3
3
|
import { createLogger } from '@aztec/foundation/log';
|
|
4
4
|
import { sleep } from '@aztec/foundation/sleep';
|
|
5
5
|
import { SlashFactoryAbi } from '@aztec/l1-artifacts';
|
|
6
|
+
import { bigIntToOffense } from '@aztec/stdlib/slashing';
|
|
6
7
|
import { encodeFunctionData, getAddress, getContract } from 'viem';
|
|
7
|
-
import { WANT_TO_SLASH_EVENT
|
|
8
|
+
import { WANT_TO_SLASH_EVENT } from './config.js';
|
|
8
9
|
/**
|
|
9
10
|
* A Spartiate slasher client implementation
|
|
10
11
|
*
|
|
@@ -39,21 +40,27 @@ import { WANT_TO_SLASH_EVENT, bigIntToOffense } from './config.js';
|
|
|
39
40
|
monitoredPayloads;
|
|
40
41
|
unwatchCallbacks;
|
|
41
42
|
overridePayloadActive;
|
|
42
|
-
|
|
43
|
+
slashingExecutionDelayInRounds;
|
|
44
|
+
static async new(config, l1Contracts, l1TxUtils, l1Client, watchers, dateProvider) {
|
|
43
45
|
if (!l1Contracts.rollupAddress) {
|
|
44
46
|
throw new Error('Cannot initialize SlasherClient without a rollup address');
|
|
45
47
|
}
|
|
46
48
|
if (!l1Contracts.slashFactoryAddress) {
|
|
47
49
|
throw new Error('Cannot initialize SlasherClient without a slashFactory address');
|
|
48
50
|
}
|
|
49
|
-
const rollup = new RollupContract(
|
|
51
|
+
const rollup = new RollupContract(l1Client, l1Contracts.rollupAddress);
|
|
50
52
|
const slashingProposer = await rollup.getSlashingProposer();
|
|
51
53
|
const slashFactoryContract = getContract({
|
|
52
54
|
address: getAddress(l1Contracts.slashFactoryAddress.toString()),
|
|
53
55
|
abi: SlashFactoryAbi,
|
|
54
|
-
client:
|
|
56
|
+
client: l1Client
|
|
55
57
|
});
|
|
56
|
-
|
|
58
|
+
const slasherClient = new SlasherClient(config, slashFactoryContract, slashingProposer, l1TxUtils, watchers, dateProvider);
|
|
59
|
+
rollup.listenToSlasherChanged(async ()=>{
|
|
60
|
+
const newSlashingProposer = await rollup.getSlashingProposer();
|
|
61
|
+
await slasherClient.setSlashingProposer(newSlashingProposer);
|
|
62
|
+
});
|
|
63
|
+
return slasherClient;
|
|
57
64
|
}
|
|
58
65
|
constructor(config, slashFactoryContract, slashingProposer, l1TxUtils, watchers, dateProvider, log = createLogger('slasher')){
|
|
59
66
|
this.config = config;
|
|
@@ -66,23 +73,26 @@ import { WANT_TO_SLASH_EVENT, bigIntToOffense } from './config.js';
|
|
|
66
73
|
this.monitoredPayloads = [];
|
|
67
74
|
this.unwatchCallbacks = [];
|
|
68
75
|
this.overridePayloadActive = false;
|
|
76
|
+
this.slashingExecutionDelayInRounds = 0n;
|
|
69
77
|
this.overridePayloadActive = config.slashOverridePayload !== undefined && !config.slashOverridePayload.isZero();
|
|
70
78
|
}
|
|
71
79
|
//////////////////// Public methods ////////////////////
|
|
72
|
-
start() {
|
|
73
|
-
this.log.
|
|
80
|
+
async start() {
|
|
81
|
+
this.log.debug('Starting Slasher client...');
|
|
82
|
+
this.slashingExecutionDelayInRounds = await this.slashingProposer.getExecutionDelayInRounds();
|
|
74
83
|
// detect when new payloads are created
|
|
75
84
|
this.unwatchCallbacks.push(this.watchSlashFactoryEvents());
|
|
76
|
-
// detect when a
|
|
77
|
-
this.unwatchCallbacks.push(this.slashingProposer.
|
|
78
|
-
// detect when a
|
|
79
|
-
this.unwatchCallbacks.push(this.slashingProposer.
|
|
85
|
+
// detect when a payload is submittable
|
|
86
|
+
this.unwatchCallbacks.push(this.slashingProposer.listenToSubmittablePayloads(this.submitRoundIfAgree.bind(this)));
|
|
87
|
+
// detect when a payload is submitted
|
|
88
|
+
this.unwatchCallbacks.push(this.slashingProposer.listenToPayloadSubmitted(this.payloadSubmitted.bind(this)));
|
|
80
89
|
// start each watcher, who will signal the slasher client when they want to slash
|
|
81
90
|
const wantToSlashCb = this.wantToSlash.bind(this);
|
|
82
91
|
for (const watcher of this.watchers){
|
|
83
92
|
watcher.on(WANT_TO_SLASH_EVENT, wantToSlashCb);
|
|
84
93
|
this.unwatchCallbacks.push(()=>watcher.removeListener(WANT_TO_SLASH_EVENT, wantToSlashCb));
|
|
85
94
|
}
|
|
95
|
+
this.log.info(`Started Slasher client${this.l1TxUtils ? ` with publisher address ${this.l1TxUtils.client.account.address}` : ''}`);
|
|
86
96
|
}
|
|
87
97
|
/**
|
|
88
98
|
* Allows consumers to stop the instance of the slasher client.
|
|
@@ -105,20 +115,27 @@ import { WANT_TO_SLASH_EVENT, bigIntToOffense } from './config.js';
|
|
|
105
115
|
this.log.warn('Clearing monitored payloads', this.monitoredPayloads);
|
|
106
116
|
this.monitoredPayloads = [];
|
|
107
117
|
}
|
|
118
|
+
async setSlashingProposer(slashingProposer) {
|
|
119
|
+
this.log.info('Slashing proposer changed');
|
|
120
|
+
// remove the old listeners
|
|
121
|
+
await this.stop();
|
|
122
|
+
this.slashingProposer = slashingProposer;
|
|
123
|
+
// start the new listeners
|
|
124
|
+
await this.start();
|
|
125
|
+
}
|
|
108
126
|
/**
|
|
109
127
|
* Update the config of the slasher client
|
|
110
128
|
*
|
|
111
|
-
* @param config - the new config.
|
|
112
|
-
* - slashOverridePayload
|
|
113
|
-
* - slashPayloadTtlSeconds
|
|
114
|
-
* - slashProposerRoundPollingIntervalSeconds
|
|
129
|
+
* @param config - the new config. Cannot update the slasher private key.
|
|
115
130
|
*/ updateConfig(config) {
|
|
131
|
+
const { slasherPrivateKey: _doNotUpdate, ...configWithoutPrivateKey } = config;
|
|
116
132
|
const newConfig = {
|
|
117
133
|
...this.config,
|
|
118
|
-
|
|
119
|
-
slashPayloadTtlSeconds: config.slashPayloadTtlSeconds ?? this.config.slashPayloadTtlSeconds,
|
|
120
|
-
slashProposerRoundPollingIntervalSeconds: config.slashProposerRoundPollingIntervalSeconds ?? this.config.slashProposerRoundPollingIntervalSeconds
|
|
134
|
+
...configWithoutPrivateKey
|
|
121
135
|
};
|
|
136
|
+
// We keep this separate flag to tell us if we should be signal for the override payload: after the override payload is executed,
|
|
137
|
+
// the slasher goes back to using the monitored payloads to inform the sequencer publisher what payload to signal for.
|
|
138
|
+
// So we only want to flip back "on" the voting for override payload if config we just passed in re-set the override payload.
|
|
122
139
|
this.overridePayloadActive = config.slashOverridePayload !== undefined && !config.slashOverridePayload.isZero();
|
|
123
140
|
this.config = newConfig;
|
|
124
141
|
}
|
|
@@ -160,17 +177,17 @@ import { WANT_TO_SLASH_EVENT, bigIntToOffense } from './config.js';
|
|
|
160
177
|
* Bound to the slashing proposer contract's listenToProposalExecuted method in `.start()`.
|
|
161
178
|
*
|
|
162
179
|
* @param {round: bigint; proposal: `0x${string}`} param0
|
|
163
|
-
*/
|
|
164
|
-
this.log.info('
|
|
180
|
+
*/ payloadSubmitted({ round, payload }) {
|
|
181
|
+
this.log.info('Payload submitted', {
|
|
165
182
|
round,
|
|
166
|
-
|
|
183
|
+
payload
|
|
167
184
|
});
|
|
168
|
-
const
|
|
185
|
+
const payloadAddress = EthAddress.fromString(payload);
|
|
169
186
|
// Stop signaling for the override payload if it was executed
|
|
170
|
-
if (this.overridePayloadActive && this.config.slashOverridePayload?.equals(
|
|
187
|
+
if (this.overridePayloadActive && this.config.slashOverridePayload?.equals(payloadAddress)) {
|
|
171
188
|
this.overridePayloadActive = false;
|
|
172
189
|
}
|
|
173
|
-
const index = this.monitoredPayloads.findIndex((p)=>p.payloadAddress.equals(
|
|
190
|
+
const index = this.monitoredPayloads.findIndex((p)=>p.payloadAddress.equals(payloadAddress));
|
|
174
191
|
if (index === -1) {
|
|
175
192
|
return;
|
|
176
193
|
}
|
|
@@ -182,6 +199,13 @@ import { WANT_TO_SLASH_EVENT, bigIntToOffense } from './config.js';
|
|
|
182
199
|
*
|
|
183
200
|
* @param args - the arguments from the watcher, including the validators, amounts, and offenses
|
|
184
201
|
*/ wantToSlash(args) {
|
|
202
|
+
if (!this.l1TxUtils) {
|
|
203
|
+
this.log.warn('Cannot slash validators: no slasher private key configured. Set SLASHER_PRIVATE_KEY environment variable.', {
|
|
204
|
+
validators: args.map((arg)=>arg.validator.toString()),
|
|
205
|
+
offenses: args.map((arg)=>arg.offense)
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
185
209
|
const sortedArgs = [
|
|
186
210
|
...args
|
|
187
211
|
].sort((a, b)=>a.validator.toString().localeCompare(b.validator.toString()));
|
|
@@ -353,32 +377,39 @@ import { WANT_TO_SLASH_EVENT, bigIntToOffense } from './config.js';
|
|
|
353
377
|
});
|
|
354
378
|
}
|
|
355
379
|
/**
|
|
356
|
-
*
|
|
380
|
+
* Submit a round to the Slasher if we agree with the payload.
|
|
357
381
|
*
|
|
358
|
-
* Bound to the slashing proposer contract's
|
|
382
|
+
* Bound to the slashing proposer contract's listenToSubmittablePayloads method in the constructor.
|
|
359
383
|
*
|
|
360
384
|
* @param {proposal: `0x${string}`; round: bigint} param0
|
|
361
|
-
*/ async
|
|
362
|
-
|
|
363
|
-
|
|
385
|
+
*/ async submitRoundIfAgree({ payload, round }) {
|
|
386
|
+
if (!this.l1TxUtils) {
|
|
387
|
+
this.log.warn('Cannot execute slashing proposal: no slasher private key configured. Set SLASHER_PRIVATE_KEY environment variable.', {
|
|
388
|
+
payload,
|
|
389
|
+
round
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const payloadAddress = EthAddress.fromString(payload);
|
|
394
|
+
if (!this.monitoredPayloads.find((p)=>p.payloadAddress.equals(payloadAddress))) {
|
|
364
395
|
this.log.debug('Round executable, but we disagree', {
|
|
365
|
-
|
|
396
|
+
payload,
|
|
366
397
|
round
|
|
367
398
|
});
|
|
368
399
|
return;
|
|
369
400
|
}
|
|
370
|
-
const
|
|
371
|
-
this.log.info(`Waiting for round ${
|
|
372
|
-
const reached = await this.slashingProposer.waitForRound(
|
|
401
|
+
const executableRound = round + BigInt(this.slashingExecutionDelayInRounds) + 1n;
|
|
402
|
+
this.log.info(`Waiting for round ${executableRound} to be reached`);
|
|
403
|
+
const reached = await this.slashingProposer.waitForRound(executableRound, this.config.slashProposerRoundPollingIntervalSeconds);
|
|
373
404
|
if (!reached) {
|
|
374
405
|
this.log.warn('Round not reached', {
|
|
375
|
-
|
|
406
|
+
payload,
|
|
376
407
|
round
|
|
377
408
|
});
|
|
378
409
|
return;
|
|
379
410
|
}
|
|
380
411
|
this.log.info('Executing round', {
|
|
381
|
-
|
|
412
|
+
payload,
|
|
382
413
|
round
|
|
383
414
|
});
|
|
384
415
|
await this.slashingProposer.executeRound(this.l1TxUtils, round).then(({ receipt })=>{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aztec/slasher",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-nightly.20250813",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./dest/index.js",
|
|
@@ -54,19 +54,19 @@
|
|
|
54
54
|
]
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@aztec/epoch-cache": "
|
|
58
|
-
"@aztec/ethereum": "
|
|
59
|
-
"@aztec/foundation": "
|
|
60
|
-
"@aztec/l1-artifacts": "
|
|
61
|
-
"@aztec/stdlib": "
|
|
62
|
-
"@aztec/telemetry-client": "
|
|
57
|
+
"@aztec/epoch-cache": "2.0.0-nightly.20250813",
|
|
58
|
+
"@aztec/ethereum": "2.0.0-nightly.20250813",
|
|
59
|
+
"@aztec/foundation": "2.0.0-nightly.20250813",
|
|
60
|
+
"@aztec/l1-artifacts": "2.0.0-nightly.20250813",
|
|
61
|
+
"@aztec/stdlib": "2.0.0-nightly.20250813",
|
|
62
|
+
"@aztec/telemetry-client": "2.0.0-nightly.20250813",
|
|
63
63
|
"source-map-support": "^0.5.21",
|
|
64
64
|
"tslib": "^2.4.0",
|
|
65
65
|
"viem": "2.23.7",
|
|
66
66
|
"zod": "^3.23.8"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@aztec/aztec.js": "
|
|
69
|
+
"@aztec/aztec.js": "2.0.0-nightly.20250813",
|
|
70
70
|
"@jest/globals": "^30.0.0",
|
|
71
71
|
"@types/jest": "^30.0.0",
|
|
72
72
|
"@types/node": "^22.15.17",
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { EpochCache } from '@aztec/epoch-cache';
|
|
2
|
+
import { type Logger, createLogger } from '@aztec/foundation/log';
|
|
3
|
+
import {
|
|
4
|
+
EthAddress,
|
|
5
|
+
type InvalidBlockDetectedEvent,
|
|
6
|
+
type L2BlockSourceEventEmitter,
|
|
7
|
+
L2BlockSourceEvents,
|
|
8
|
+
PublishedL2Block,
|
|
9
|
+
type ValidateBlockNegativeResult,
|
|
10
|
+
} from '@aztec/stdlib/block';
|
|
11
|
+
import { Offense } from '@aztec/stdlib/slashing';
|
|
12
|
+
|
|
13
|
+
import EventEmitter from 'node:events';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type SlasherConfig,
|
|
17
|
+
WANT_TO_SLASH_EVENT,
|
|
18
|
+
type WantToSlashArgs,
|
|
19
|
+
type Watcher,
|
|
20
|
+
type WatcherEmitter,
|
|
21
|
+
} from './config.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* This watcher is responsible for detecting invalid blocks and creating slashing arguments for offenders.
|
|
25
|
+
* An invalid block is one that doesn't have enough attestations or has incorrect attestations.
|
|
26
|
+
* The proposer of an invalid block should be slashed.
|
|
27
|
+
* If there's another block consecutive to the invalid one, its proposer and attestors should also be slashed.
|
|
28
|
+
*/
|
|
29
|
+
export class AttestationsBlockWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
|
|
30
|
+
private log: Logger = createLogger('attestations-block-watcher');
|
|
31
|
+
|
|
32
|
+
// Only keep track of the last N invalid blocks
|
|
33
|
+
private maxInvalidBlocks = 100;
|
|
34
|
+
|
|
35
|
+
// All invalid archive roots seen
|
|
36
|
+
private invalidArchiveRoots: Set<string> = new Set();
|
|
37
|
+
|
|
38
|
+
// TODO(#16140): Bad validators are never cleared even after slashing
|
|
39
|
+
private badAttestors: Set<string> = new Set();
|
|
40
|
+
private badProposers: Set<string> = new Set();
|
|
41
|
+
|
|
42
|
+
private boundHandleInvalidBlock = (event: InvalidBlockDetectedEvent) => {
|
|
43
|
+
try {
|
|
44
|
+
this.handleInvalidBlock(event);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
this.log.error('Error handling invalid block', err, {
|
|
47
|
+
...event.validationResult.block.block.toBlockInfo(),
|
|
48
|
+
...event.validationResult.block.l1,
|
|
49
|
+
reason: event.validationResult.reason,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private l2BlockSource: L2BlockSourceEventEmitter,
|
|
56
|
+
private epochCache: EpochCache,
|
|
57
|
+
private config: Pick<
|
|
58
|
+
SlasherConfig,
|
|
59
|
+
| 'slashAttestDescendantOfInvalidPenalty'
|
|
60
|
+
| 'slashAttestDescendantOfInvalidMaxPenalty'
|
|
61
|
+
| 'slashProposeInvalidAttestationsPenalty'
|
|
62
|
+
| 'slashProposeInvalidAttestationsMaxPenalty'
|
|
63
|
+
>,
|
|
64
|
+
) {
|
|
65
|
+
super();
|
|
66
|
+
this.log.info('InvalidBlockWatcher initialized');
|
|
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
|
+
public shouldSlash({ amount, offense, validator }: WantToSlashArgs): Promise<boolean> {
|
|
83
|
+
const maxPenalty = this.getMaxPenalty(offense);
|
|
84
|
+
const logData = { validator, amount, offense, maxPenalty };
|
|
85
|
+
if (amount > maxPenalty) {
|
|
86
|
+
this.log.warn(`Slash amount ${amount} exceeds maximum penalty ${maxPenalty} for offense ${offense}`, logData);
|
|
87
|
+
return Promise.resolve(false);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (this.hasOffended(offense, validator)) {
|
|
91
|
+
this.log.verbose(`Agreeing to slash validator ${validator} for offense ${offense}`, logData);
|
|
92
|
+
return Promise.resolve(true);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.log.debug(`Refusing to slash validator ${validator} for offense ${offense}`, logData);
|
|
96
|
+
return Promise.resolve(false);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private handleInvalidBlock(event: InvalidBlockDetectedEvent): void {
|
|
100
|
+
const { validationResult } = event;
|
|
101
|
+
const block = validationResult.block.block;
|
|
102
|
+
|
|
103
|
+
// Check if we already have processed this block, archiver may emit the same event multiple times
|
|
104
|
+
if (this.invalidArchiveRoots.has(block.archive.root.toString())) {
|
|
105
|
+
this.log.trace(`Already processed invalid block ${block.number}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.log.verbose(`Detected invalid block ${block.number}`, {
|
|
110
|
+
...block.toBlockInfo(),
|
|
111
|
+
reason: validationResult.valid === false ? validationResult.reason : 'unknown',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Store the invalid block
|
|
115
|
+
this.addInvalidBlock(event.validationResult.block);
|
|
116
|
+
|
|
117
|
+
// Slash the proposer of the invalid block
|
|
118
|
+
this.slashProposer(event.validationResult);
|
|
119
|
+
|
|
120
|
+
// Check if the parent of this block is invalid as well, if so, we will slash its attestors as well
|
|
121
|
+
this.slashAttestorsOnAncestorInvalid(event.validationResult);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private slashAttestorsOnAncestorInvalid(validationResult: ValidateBlockNegativeResult) {
|
|
125
|
+
const block = validationResult.block;
|
|
126
|
+
|
|
127
|
+
const parentArchive = block.block.header.lastArchive.root.toString();
|
|
128
|
+
if (this.invalidArchiveRoots.has(block.block.header.lastArchive.root.toString())) {
|
|
129
|
+
const attestors = validationResult.attestations.map(a => a.getSender());
|
|
130
|
+
this.log.info(`Want to slash attestors of block ${block.block.number} built on invalid block`, {
|
|
131
|
+
...block.block.toBlockInfo(),
|
|
132
|
+
...attestors,
|
|
133
|
+
parentArchive,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
attestors.forEach(attestor => this.badAttestors.add(attestor.toString()));
|
|
137
|
+
|
|
138
|
+
this.emit(
|
|
139
|
+
WANT_TO_SLASH_EVENT,
|
|
140
|
+
attestors.map(attestor => ({
|
|
141
|
+
validator: attestor,
|
|
142
|
+
amount: this.config.slashAttestDescendantOfInvalidPenalty,
|
|
143
|
+
offense: Offense.ATTESTED_DESCENDANT_OF_INVALID,
|
|
144
|
+
})),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private slashProposer(validationResult: ValidateBlockNegativeResult) {
|
|
150
|
+
const { reason, block } = validationResult;
|
|
151
|
+
const blockNumber = block.block.number;
|
|
152
|
+
const slot = block.block.header.getSlot();
|
|
153
|
+
const proposer = this.epochCache.getProposerFromEpochCommittee(validationResult, slot);
|
|
154
|
+
|
|
155
|
+
if (!proposer) {
|
|
156
|
+
this.log.warn(`No proposer found for block ${blockNumber} at slot ${slot}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const offense = this.getOffenseFromInvalidationReason(reason);
|
|
161
|
+
const amount = this.config.slashProposeInvalidAttestationsPenalty;
|
|
162
|
+
const args: WantToSlashArgs = { validator: proposer, amount, offense };
|
|
163
|
+
|
|
164
|
+
this.log.info(`Want to slash proposer of block ${blockNumber} due to ${reason}`, {
|
|
165
|
+
...block.block.toBlockInfo(),
|
|
166
|
+
...args,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
this.badProposers.add(proposer.toString());
|
|
170
|
+
this.emit(WANT_TO_SLASH_EVENT, [args]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getOffenseFromInvalidationReason(reason: ValidateBlockNegativeResult['reason']): Offense {
|
|
174
|
+
switch (reason) {
|
|
175
|
+
case 'invalid-attestation':
|
|
176
|
+
return Offense.PROPOSED_INCORRECT_ATTESTATIONS;
|
|
177
|
+
case 'insufficient-attestations':
|
|
178
|
+
return Offense.PROPOSED_INSUFFICIENT_ATTESTATIONS;
|
|
179
|
+
default: {
|
|
180
|
+
const _: never = reason;
|
|
181
|
+
return Offense.UNKNOWN;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private getMaxPenalty(offense: Offense) {
|
|
187
|
+
switch (offense) {
|
|
188
|
+
case Offense.PROPOSED_INCORRECT_ATTESTATIONS:
|
|
189
|
+
case Offense.PROPOSED_INSUFFICIENT_ATTESTATIONS:
|
|
190
|
+
return this.config.slashProposeInvalidAttestationsMaxPenalty;
|
|
191
|
+
case Offense.ATTESTED_DESCENDANT_OF_INVALID:
|
|
192
|
+
return this.config.slashProposeInvalidAttestationsMaxPenalty;
|
|
193
|
+
default:
|
|
194
|
+
return 0n;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private hasOffended(offense: Offense, validator: EthAddress): boolean {
|
|
199
|
+
switch (offense) {
|
|
200
|
+
case Offense.PROPOSED_INCORRECT_ATTESTATIONS:
|
|
201
|
+
case Offense.PROPOSED_INSUFFICIENT_ATTESTATIONS:
|
|
202
|
+
return this.badProposers.has(validator.toString());
|
|
203
|
+
case Offense.ATTESTED_DESCENDANT_OF_INVALID:
|
|
204
|
+
return this.badAttestors.has(validator.toString());
|
|
205
|
+
default:
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private addInvalidBlock(block: PublishedL2Block) {
|
|
211
|
+
this.invalidArchiveRoots.add(block.block.archive.root.toString());
|
|
212
|
+
|
|
213
|
+
// Prune old entries if we exceed the maximum
|
|
214
|
+
if (this.invalidArchiveRoots.size > this.maxInvalidBlocks) {
|
|
215
|
+
const oldestKey = this.invalidArchiveRoots.keys().next().value!;
|
|
216
|
+
this.invalidArchiveRoots.delete(oldestKey);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,45 +1,19 @@
|
|
|
1
|
+
import { NULL_KEY } from '@aztec/ethereum';
|
|
1
2
|
import type { ConfigMappingsType } from '@aztec/foundation/config';
|
|
2
3
|
import {
|
|
4
|
+
SecretValue,
|
|
3
5
|
bigintConfigHelper,
|
|
4
6
|
booleanConfigHelper,
|
|
5
7
|
floatConfigHelper,
|
|
6
8
|
numberConfigHelper,
|
|
9
|
+
secretValueConfigHelper,
|
|
7
10
|
} from '@aztec/foundation/config';
|
|
8
11
|
import { EthAddress } from '@aztec/foundation/eth-address';
|
|
9
12
|
import type { TypedEventEmitter } from '@aztec/foundation/types';
|
|
13
|
+
import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
|
|
14
|
+
import { Offense } from '@aztec/stdlib/slashing';
|
|
10
15
|
|
|
11
|
-
export
|
|
12
|
-
UNKNOWN = 0,
|
|
13
|
-
DATA_WITHHOLDING = 1,
|
|
14
|
-
VALID_EPOCH_PRUNED = 2,
|
|
15
|
-
INACTIVITY = 3,
|
|
16
|
-
INVALID_BLOCK = 4,
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const OffenseToBigInt: Record<Offense, bigint> = {
|
|
20
|
-
[Offense.UNKNOWN]: 0n,
|
|
21
|
-
[Offense.DATA_WITHHOLDING]: 1n,
|
|
22
|
-
[Offense.VALID_EPOCH_PRUNED]: 2n,
|
|
23
|
-
[Offense.INACTIVITY]: 3n,
|
|
24
|
-
[Offense.INVALID_BLOCK]: 4n,
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export function bigIntToOffense(offense: bigint): Offense {
|
|
28
|
-
switch (offense) {
|
|
29
|
-
case 0n:
|
|
30
|
-
return Offense.UNKNOWN;
|
|
31
|
-
case 1n:
|
|
32
|
-
return Offense.DATA_WITHHOLDING;
|
|
33
|
-
case 2n:
|
|
34
|
-
return Offense.VALID_EPOCH_PRUNED;
|
|
35
|
-
case 3n:
|
|
36
|
-
return Offense.INACTIVITY;
|
|
37
|
-
case 4n:
|
|
38
|
-
return Offense.INVALID_BLOCK;
|
|
39
|
-
default:
|
|
40
|
-
throw new Error(`Unknown offense: ${offense}`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
16
|
+
export type { SlasherConfig };
|
|
43
17
|
|
|
44
18
|
export const WANT_TO_SLASH_EVENT = 'wantToSlash' as const;
|
|
45
19
|
|
|
@@ -64,25 +38,6 @@ export type Watcher = WatcherEmitter & {
|
|
|
64
38
|
stop?: () => Promise<void>;
|
|
65
39
|
};
|
|
66
40
|
|
|
67
|
-
export interface SlasherConfig {
|
|
68
|
-
// New configurations based on design doc
|
|
69
|
-
slashOverridePayload?: EthAddress;
|
|
70
|
-
slashPayloadTtlSeconds: number; // TTL for payloads, in seconds
|
|
71
|
-
slashPruneEnabled: boolean;
|
|
72
|
-
slashPrunePenalty: bigint;
|
|
73
|
-
slashPruneMaxPenalty: bigint;
|
|
74
|
-
slashInvalidBlockEnabled: boolean;
|
|
75
|
-
slashInvalidBlockPenalty: bigint;
|
|
76
|
-
slashInvalidBlockMaxPenalty: bigint;
|
|
77
|
-
slashInactivityEnabled: boolean;
|
|
78
|
-
slashInactivityCreateTargetPercentage: number; // 0-1, 0.9 means 90%. Must be greater than 0
|
|
79
|
-
slashInactivitySignalTargetPercentage: number; // 0-1, 0.6 means 60%. Must be greater than 0
|
|
80
|
-
slashInactivityCreatePenalty: bigint;
|
|
81
|
-
slashInactivityMaxPenalty: bigint;
|
|
82
|
-
slashProposerRoundPollingIntervalSeconds: number;
|
|
83
|
-
// Consider adding: slashInactivityCreateEnabled: boolean;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
41
|
export const DefaultSlasherConfig: SlasherConfig = {
|
|
87
42
|
slashPayloadTtlSeconds: 60 * 60 * 24, // 1 day
|
|
88
43
|
slashOverridePayload: undefined,
|
|
@@ -97,7 +52,12 @@ export const DefaultSlasherConfig: SlasherConfig = {
|
|
|
97
52
|
slashInactivitySignalTargetPercentage: 0.6,
|
|
98
53
|
slashInactivityCreatePenalty: 1n,
|
|
99
54
|
slashInactivityMaxPenalty: 100n,
|
|
55
|
+
slashProposeInvalidAttestationsPenalty: 1n,
|
|
56
|
+
slashProposeInvalidAttestationsMaxPenalty: 100n,
|
|
57
|
+
slashAttestDescendantOfInvalidPenalty: 1n,
|
|
58
|
+
slashAttestDescendantOfInvalidMaxPenalty: 100n,
|
|
100
59
|
slashProposerRoundPollingIntervalSeconds: 12,
|
|
60
|
+
slasherPrivateKey: new SecretValue<string | undefined>(undefined),
|
|
101
61
|
};
|
|
102
62
|
|
|
103
63
|
export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
|
|
@@ -177,8 +137,32 @@ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
|
|
|
177
137
|
description: 'Maximum penalty amount for slashing an inactive validator.',
|
|
178
138
|
...bigintConfigHelper(DefaultSlasherConfig.slashInactivityMaxPenalty),
|
|
179
139
|
},
|
|
140
|
+
slashProposeInvalidAttestationsPenalty: {
|
|
141
|
+
env: 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY',
|
|
142
|
+
description: 'Penalty amount for slashing a proposer that proposed invalid attestations.',
|
|
143
|
+
...bigintConfigHelper(DefaultSlasherConfig.slashProposeInvalidAttestationsPenalty),
|
|
144
|
+
},
|
|
145
|
+
slashProposeInvalidAttestationsMaxPenalty: {
|
|
146
|
+
env: 'SLASH_PROPOSE_INVALID_ATTESTATIONS_MAX_PENALTY',
|
|
147
|
+
description: 'Maximum penalty amount for slashing a proposer that proposed invalid attestations.',
|
|
148
|
+
...bigintConfigHelper(DefaultSlasherConfig.slashProposeInvalidAttestationsMaxPenalty),
|
|
149
|
+
},
|
|
150
|
+
slashAttestDescendantOfInvalidPenalty: {
|
|
151
|
+
env: 'SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY',
|
|
152
|
+
description: 'Penalty amount for slashing a validator that attested to a descendant of an invalid block.',
|
|
153
|
+
...bigintConfigHelper(DefaultSlasherConfig.slashAttestDescendantOfInvalidPenalty),
|
|
154
|
+
},
|
|
155
|
+
slashAttestDescendantOfInvalidMaxPenalty: {
|
|
156
|
+
env: 'SLASH_ATTEST_DESCENDANT_OF_INVALID_MAX_PENALTY',
|
|
157
|
+
description: 'Maximum penalty amount for slashing a validator that attested to a descendant of an invalid block.',
|
|
158
|
+
...bigintConfigHelper(DefaultSlasherConfig.slashAttestDescendantOfInvalidMaxPenalty),
|
|
159
|
+
},
|
|
180
160
|
slashProposerRoundPollingIntervalSeconds: {
|
|
181
161
|
description: 'Polling interval for slashing proposer round in seconds.',
|
|
182
162
|
...numberConfigHelper(DefaultSlasherConfig.slashProposerRoundPollingIntervalSeconds),
|
|
183
163
|
},
|
|
164
|
+
slasherPrivateKey: {
|
|
165
|
+
description: 'Private key used for creating slash payloads.',
|
|
166
|
+
...secretValueConfigHelper(val => (val ? `0x${val.replace('0x', '')}` : NULL_KEY)),
|
|
167
|
+
},
|
|
184
168
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Tx } from '@aztec/aztec.js';
|
|
1
2
|
import { EpochCache } from '@aztec/epoch-cache';
|
|
2
3
|
import { type Logger, createLogger } from '@aztec/foundation/log';
|
|
3
4
|
import {
|
|
@@ -7,8 +8,9 @@ import {
|
|
|
7
8
|
type L2BlockSourceEventEmitter,
|
|
8
9
|
L2BlockSourceEvents,
|
|
9
10
|
} from '@aztec/stdlib/block';
|
|
10
|
-
import type { IFullNodeBlockBuilder,
|
|
11
|
+
import type { IFullNodeBlockBuilder, ITxProvider, MerkleTreeWriteOperations } from '@aztec/stdlib/interfaces/server';
|
|
11
12
|
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
|
|
13
|
+
import { Offense } from '@aztec/stdlib/slashing';
|
|
12
14
|
import {
|
|
13
15
|
ReExFailedTxsError,
|
|
14
16
|
ReExStateMismatchError,
|
|
@@ -18,7 +20,7 @@ import {
|
|
|
18
20
|
|
|
19
21
|
import EventEmitter from 'node:events';
|
|
20
22
|
|
|
21
|
-
import {
|
|
23
|
+
import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from './config.js';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* This watcher is responsible for detecting chain prunes and creating slashing arguments for the committee.
|
|
@@ -34,11 +36,14 @@ export class EpochPruneWatcher extends (EventEmitter as new () => WatcherEmitter
|
|
|
34
36
|
// Only keep track of the last N slashable epochs
|
|
35
37
|
private maxSlashableEpochs = 100;
|
|
36
38
|
|
|
39
|
+
// Store bound function reference for proper listener removal
|
|
40
|
+
private boundHandlePruneL2Blocks = this.handlePruneL2Blocks.bind(this);
|
|
41
|
+
|
|
37
42
|
constructor(
|
|
38
43
|
private l2BlockSource: L2BlockSourceEventEmitter,
|
|
39
44
|
private l1ToL2MessageSource: L1ToL2MessageSource,
|
|
40
45
|
private epochCache: EpochCache,
|
|
41
|
-
private
|
|
46
|
+
private txProvider: Pick<ITxProvider, 'getAvailableTxs'>,
|
|
42
47
|
private blockBuilder: IFullNodeBlockBuilder,
|
|
43
48
|
private penalty: bigint,
|
|
44
49
|
private maxPenalty: bigint,
|
|
@@ -48,12 +53,12 @@ export class EpochPruneWatcher extends (EventEmitter as new () => WatcherEmitter
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
public start() {
|
|
51
|
-
this.l2BlockSource.on(L2BlockSourceEvents.L2PruneDetected, this.
|
|
56
|
+
this.l2BlockSource.on(L2BlockSourceEvents.L2PruneDetected, this.boundHandlePruneL2Blocks);
|
|
52
57
|
return Promise.resolve();
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
public stop() {
|
|
56
|
-
this.l2BlockSource.removeListener(L2BlockSourceEvents.L2PruneDetected, this.
|
|
61
|
+
this.l2BlockSource.removeListener(L2BlockSourceEvents.L2PruneDetected, this.boundHandlePruneL2Blocks);
|
|
57
62
|
return Promise.resolve();
|
|
58
63
|
}
|
|
59
64
|
|
|
@@ -120,16 +125,18 @@ export class EpochPruneWatcher extends (EventEmitter as new () => WatcherEmitter
|
|
|
120
125
|
public async validateBlock(blockFromL1: L2Block, fork: MerkleTreeWriteOperations): Promise<void> {
|
|
121
126
|
this.log.debug(`Validating pruned block ${blockFromL1.header.globalVariables.blockNumber}`);
|
|
122
127
|
const txHashes = blockFromL1.body.txEffects.map(txEffect => txEffect.txHash);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
// We load txs from the mempool directly, since the TxCollector running in the background has already been
|
|
129
|
+
// trying to fetch them from nodes or via reqresp. If we haven't managed to collect them by now,
|
|
130
|
+
// it's likely that they are not available in the network at all.
|
|
131
|
+
const { txs, missingTxs } = await this.txProvider.getAvailableTxs(txHashes);
|
|
132
|
+
|
|
133
|
+
if (missingTxs && missingTxs.length > 0) {
|
|
134
|
+
throw new TransactionsNotAvailableError(missingTxs);
|
|
129
135
|
}
|
|
136
|
+
|
|
130
137
|
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(blockFromL1.number);
|
|
131
138
|
const { block, failedTxs, numTxs } = await this.blockBuilder.buildBlock(
|
|
132
|
-
txs,
|
|
139
|
+
txs as Tx[],
|
|
133
140
|
l1ToL2Messages,
|
|
134
141
|
blockFromL1.header.globalVariables,
|
|
135
142
|
{},
|