@aztec/slasher 1.2.1 → 2.0.0-nightly.20250814
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 +2 -9
- 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 +16 -13
- package/dest/slasher_client.d.ts.map +1 -1
- package/dest/slasher_client.js +63 -30
- package/package.json +8 -8
- package/src/attestations_block_watcher.ts +219 -0
- package/src/config.ts +34 -32
- package/src/epoch_prune_watcher.ts +19 -12
- package/src/index.ts +2 -0
- package/src/slasher_client.ts +88 -35
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,14 +115,23 @@ 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.
|
|
129
|
+
* @param config - the new config. Cannot update the slasher private key.
|
|
112
130
|
*/ updateConfig(config) {
|
|
131
|
+
const { slasherPrivateKey: _doNotUpdate, ...configWithoutPrivateKey } = config;
|
|
113
132
|
const newConfig = {
|
|
114
133
|
...this.config,
|
|
115
|
-
...
|
|
134
|
+
...configWithoutPrivateKey
|
|
116
135
|
};
|
|
117
136
|
// We keep this separate flag to tell us if we should be signal for the override payload: after the override payload is executed,
|
|
118
137
|
// the slasher goes back to using the monitored payloads to inform the sequencer publisher what payload to signal for.
|
|
@@ -158,17 +177,17 @@ import { WANT_TO_SLASH_EVENT, bigIntToOffense } from './config.js';
|
|
|
158
177
|
* Bound to the slashing proposer contract's listenToProposalExecuted method in `.start()`.
|
|
159
178
|
*
|
|
160
179
|
* @param {round: bigint; proposal: `0x${string}`} param0
|
|
161
|
-
*/
|
|
162
|
-
this.log.info('
|
|
180
|
+
*/ payloadSubmitted({ round, payload }) {
|
|
181
|
+
this.log.info('Payload submitted', {
|
|
163
182
|
round,
|
|
164
|
-
|
|
183
|
+
payload
|
|
165
184
|
});
|
|
166
|
-
const
|
|
185
|
+
const payloadAddress = EthAddress.fromString(payload);
|
|
167
186
|
// Stop signaling for the override payload if it was executed
|
|
168
|
-
if (this.overridePayloadActive && this.config.slashOverridePayload?.equals(
|
|
187
|
+
if (this.overridePayloadActive && this.config.slashOverridePayload?.equals(payloadAddress)) {
|
|
169
188
|
this.overridePayloadActive = false;
|
|
170
189
|
}
|
|
171
|
-
const index = this.monitoredPayloads.findIndex((p)=>p.payloadAddress.equals(
|
|
190
|
+
const index = this.monitoredPayloads.findIndex((p)=>p.payloadAddress.equals(payloadAddress));
|
|
172
191
|
if (index === -1) {
|
|
173
192
|
return;
|
|
174
193
|
}
|
|
@@ -180,6 +199,13 @@ import { WANT_TO_SLASH_EVENT, bigIntToOffense } from './config.js';
|
|
|
180
199
|
*
|
|
181
200
|
* @param args - the arguments from the watcher, including the validators, amounts, and offenses
|
|
182
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
|
+
}
|
|
183
209
|
const sortedArgs = [
|
|
184
210
|
...args
|
|
185
211
|
].sort((a, b)=>a.validator.toString().localeCompare(b.validator.toString()));
|
|
@@ -351,32 +377,39 @@ import { WANT_TO_SLASH_EVENT, bigIntToOffense } from './config.js';
|
|
|
351
377
|
});
|
|
352
378
|
}
|
|
353
379
|
/**
|
|
354
|
-
*
|
|
380
|
+
* Submit a round to the Slasher if we agree with the payload.
|
|
355
381
|
*
|
|
356
|
-
* Bound to the slashing proposer contract's
|
|
382
|
+
* Bound to the slashing proposer contract's listenToSubmittablePayloads method in the constructor.
|
|
357
383
|
*
|
|
358
384
|
* @param {proposal: `0x${string}`; round: bigint} param0
|
|
359
|
-
*/ async
|
|
360
|
-
|
|
361
|
-
|
|
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))) {
|
|
362
395
|
this.log.debug('Round executable, but we disagree', {
|
|
363
|
-
|
|
396
|
+
payload,
|
|
364
397
|
round
|
|
365
398
|
});
|
|
366
399
|
return;
|
|
367
400
|
}
|
|
368
|
-
const
|
|
369
|
-
this.log.info(`Waiting for round ${
|
|
370
|
-
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);
|
|
371
404
|
if (!reached) {
|
|
372
405
|
this.log.warn('Round not reached', {
|
|
373
|
-
|
|
406
|
+
payload,
|
|
374
407
|
round
|
|
375
408
|
});
|
|
376
409
|
return;
|
|
377
410
|
}
|
|
378
411
|
this.log.info('Executing round', {
|
|
379
|
-
|
|
412
|
+
payload,
|
|
380
413
|
round
|
|
381
414
|
});
|
|
382
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.20250814",
|
|
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.20250814",
|
|
58
|
+
"@aztec/ethereum": "2.0.0-nightly.20250814",
|
|
59
|
+
"@aztec/foundation": "2.0.0-nightly.20250814",
|
|
60
|
+
"@aztec/l1-artifacts": "2.0.0-nightly.20250814",
|
|
61
|
+
"@aztec/stdlib": "2.0.0-nightly.20250814",
|
|
62
|
+
"@aztec/telemetry-client": "2.0.0-nightly.20250814",
|
|
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.20250814",
|
|
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,46 +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';
|
|
10
13
|
import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
|
|
14
|
+
import { Offense } from '@aztec/stdlib/slashing';
|
|
11
15
|
|
|
12
|
-
export
|
|
13
|
-
UNKNOWN = 0,
|
|
14
|
-
DATA_WITHHOLDING = 1,
|
|
15
|
-
VALID_EPOCH_PRUNED = 2,
|
|
16
|
-
INACTIVITY = 3,
|
|
17
|
-
INVALID_BLOCK = 4,
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const OffenseToBigInt: Record<Offense, bigint> = {
|
|
21
|
-
[Offense.UNKNOWN]: 0n,
|
|
22
|
-
[Offense.DATA_WITHHOLDING]: 1n,
|
|
23
|
-
[Offense.VALID_EPOCH_PRUNED]: 2n,
|
|
24
|
-
[Offense.INACTIVITY]: 3n,
|
|
25
|
-
[Offense.INVALID_BLOCK]: 4n,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export function bigIntToOffense(offense: bigint): Offense {
|
|
29
|
-
switch (offense) {
|
|
30
|
-
case 0n:
|
|
31
|
-
return Offense.UNKNOWN;
|
|
32
|
-
case 1n:
|
|
33
|
-
return Offense.DATA_WITHHOLDING;
|
|
34
|
-
case 2n:
|
|
35
|
-
return Offense.VALID_EPOCH_PRUNED;
|
|
36
|
-
case 3n:
|
|
37
|
-
return Offense.INACTIVITY;
|
|
38
|
-
case 4n:
|
|
39
|
-
return Offense.INVALID_BLOCK;
|
|
40
|
-
default:
|
|
41
|
-
throw new Error(`Unknown offense: ${offense}`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
16
|
+
export type { SlasherConfig };
|
|
44
17
|
|
|
45
18
|
export const WANT_TO_SLASH_EVENT = 'wantToSlash' as const;
|
|
46
19
|
|
|
@@ -79,7 +52,12 @@ export const DefaultSlasherConfig: SlasherConfig = {
|
|
|
79
52
|
slashInactivitySignalTargetPercentage: 0.6,
|
|
80
53
|
slashInactivityCreatePenalty: 1n,
|
|
81
54
|
slashInactivityMaxPenalty: 100n,
|
|
55
|
+
slashProposeInvalidAttestationsPenalty: 1n,
|
|
56
|
+
slashProposeInvalidAttestationsMaxPenalty: 100n,
|
|
57
|
+
slashAttestDescendantOfInvalidPenalty: 1n,
|
|
58
|
+
slashAttestDescendantOfInvalidMaxPenalty: 100n,
|
|
82
59
|
slashProposerRoundPollingIntervalSeconds: 12,
|
|
60
|
+
slasherPrivateKey: new SecretValue<string | undefined>(undefined),
|
|
83
61
|
};
|
|
84
62
|
|
|
85
63
|
export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
|
|
@@ -159,8 +137,32 @@ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
|
|
|
159
137
|
description: 'Maximum penalty amount for slashing an inactive validator.',
|
|
160
138
|
...bigintConfigHelper(DefaultSlasherConfig.slashInactivityMaxPenalty),
|
|
161
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
|
+
},
|
|
162
160
|
slashProposerRoundPollingIntervalSeconds: {
|
|
163
161
|
description: 'Polling interval for slashing proposer round in seconds.',
|
|
164
162
|
...numberConfigHelper(DefaultSlasherConfig.slashProposerRoundPollingIntervalSeconds),
|
|
165
163
|
},
|
|
164
|
+
slasherPrivateKey: {
|
|
165
|
+
description: 'Private key used for creating slash payloads.',
|
|
166
|
+
...secretValueConfigHelper(val => (val ? `0x${val.replace('0x', '')}` : NULL_KEY)),
|
|
167
|
+
},
|
|
166
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
|
{},
|