@aztec-labs/slasher 6.0.0-nightly.20260829

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.
Files changed (98) hide show
  1. package/README.md +232 -0
  2. package/dest/config.d.ts +6 -0
  3. package/dest/config.d.ts.map +1 -0
  4. package/dest/config.js +146 -0
  5. package/dest/factory/create_facade.d.ts +19 -0
  6. package/dest/factory/create_facade.d.ts.map +1 -0
  7. package/dest/factory/create_facade.js +49 -0
  8. package/dest/factory/create_implementation.d.ts +17 -0
  9. package/dest/factory/create_implementation.d.ts.map +1 -0
  10. package/dest/factory/create_implementation.js +25 -0
  11. package/dest/factory/get_settings.d.ts +4 -0
  12. package/dest/factory/get_settings.d.ts.map +1 -0
  13. package/dest/factory/get_settings.js +36 -0
  14. package/dest/factory/index.d.ts +3 -0
  15. package/dest/factory/index.d.ts.map +1 -0
  16. package/dest/factory/index.js +2 -0
  17. package/dest/generated/slasher-defaults.d.ts +21 -0
  18. package/dest/generated/slasher-defaults.d.ts.map +1 -0
  19. package/dest/generated/slasher-defaults.js +21 -0
  20. package/dest/index.d.ts +13 -0
  21. package/dest/index.d.ts.map +1 -0
  22. package/dest/index.js +12 -0
  23. package/dest/metrics.d.ts +23 -0
  24. package/dest/metrics.d.ts.map +1 -0
  25. package/dest/metrics.js +38 -0
  26. package/dest/null_slasher_client.d.ts +16 -0
  27. package/dest/null_slasher_client.d.ts.map +1 -0
  28. package/dest/null_slasher_client.js +30 -0
  29. package/dest/own_validator_slash_monitor.d.ts +66 -0
  30. package/dest/own_validator_slash_monitor.d.ts.map +1 -0
  31. package/dest/own_validator_slash_monitor.js +163 -0
  32. package/dest/slash_offenses_collector.d.ts +46 -0
  33. package/dest/slash_offenses_collector.d.ts.map +1 -0
  34. package/dest/slash_offenses_collector.js +114 -0
  35. package/dest/slash_round_monitor.d.ts +30 -0
  36. package/dest/slash_round_monitor.d.ts.map +1 -0
  37. package/dest/slash_round_monitor.js +52 -0
  38. package/dest/slasher_client.d.ts +118 -0
  39. package/dest/slasher_client.d.ts.map +1 -0
  40. package/dest/slasher_client.js +373 -0
  41. package/dest/slasher_client_facade.d.ts +44 -0
  42. package/dest/slasher_client_facade.d.ts.map +1 -0
  43. package/dest/slasher_client_facade.js +75 -0
  44. package/dest/slasher_client_interface.d.ts +25 -0
  45. package/dest/slasher_client_interface.d.ts.map +1 -0
  46. package/dest/slasher_client_interface.js +1 -0
  47. package/dest/stores/offenses_store.d.ts +37 -0
  48. package/dest/stores/offenses_store.d.ts.map +1 -0
  49. package/dest/stores/offenses_store.js +130 -0
  50. package/dest/stores/schema_version.d.ts +2 -0
  51. package/dest/stores/schema_version.d.ts.map +1 -0
  52. package/dest/stores/schema_version.js +1 -0
  53. package/dest/test/dummy_watcher.d.ts +11 -0
  54. package/dest/test/dummy_watcher.d.ts.map +1 -0
  55. package/dest/test/dummy_watcher.js +14 -0
  56. package/dest/watcher.d.ts +28 -0
  57. package/dest/watcher.d.ts.map +1 -0
  58. package/dest/watcher.js +2 -0
  59. package/dest/watchers/attestations_block_watcher.d.ts +47 -0
  60. package/dest/watchers/attestations_block_watcher.d.ts.map +1 -0
  61. package/dest/watchers/attestations_block_watcher.js +157 -0
  62. package/dest/watchers/attested_invalid_proposal_watcher.d.ts +42 -0
  63. package/dest/watchers/attested_invalid_proposal_watcher.d.ts.map +1 -0
  64. package/dest/watchers/attested_invalid_proposal_watcher.js +117 -0
  65. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts +38 -0
  66. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts.map +1 -0
  67. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.js +138 -0
  68. package/dest/watchers/checkpoint_equivocation_watcher.d.ts +30 -0
  69. package/dest/watchers/checkpoint_equivocation_watcher.d.ts.map +1 -0
  70. package/dest/watchers/checkpoint_equivocation_watcher.js +69 -0
  71. package/dest/watchers/data_withholding_watcher.d.ts +69 -0
  72. package/dest/watchers/data_withholding_watcher.d.ts.map +1 -0
  73. package/dest/watchers/data_withholding_watcher.js +223 -0
  74. package/package.json +90 -0
  75. package/src/config.ts +177 -0
  76. package/src/factory/create_facade.ts +85 -0
  77. package/src/factory/create_implementation.ts +83 -0
  78. package/src/factory/get_settings.ts +58 -0
  79. package/src/factory/index.ts +2 -0
  80. package/src/generated/slasher-defaults.ts +23 -0
  81. package/src/index.ts +12 -0
  82. package/src/metrics.ts +58 -0
  83. package/src/null_slasher_client.ts +37 -0
  84. package/src/own_validator_slash_monitor.ts +183 -0
  85. package/src/slash_offenses_collector.ts +156 -0
  86. package/src/slash_round_monitor.ts +62 -0
  87. package/src/slasher_client.ts +477 -0
  88. package/src/slasher_client_facade.ts +99 -0
  89. package/src/slasher_client_interface.ts +31 -0
  90. package/src/stores/offenses_store.ts +173 -0
  91. package/src/stores/schema_version.ts +1 -0
  92. package/src/test/dummy_watcher.ts +20 -0
  93. package/src/watcher.ts +35 -0
  94. package/src/watchers/attestations_block_watcher.ts +199 -0
  95. package/src/watchers/attested_invalid_proposal_watcher.ts +167 -0
  96. package/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts +191 -0
  97. package/src/watchers/checkpoint_equivocation_watcher.ts +95 -0
  98. package/src/watchers/data_withholding_watcher.ts +256 -0
@@ -0,0 +1,58 @@
1
+ import type { RollupContract, SlashingProposerContract } from '@aztec-labs/ethereum/contracts';
2
+
3
+ import type { SlasherSettings } from '../slasher_client.js';
4
+
5
+ export async function getSlasherSettings(
6
+ rollup: RollupContract,
7
+ slashingProposer?: SlashingProposerContract,
8
+ ): Promise<Omit<SlasherSettings, 'rollupRegisteredAtL2Slot'>> {
9
+ if (!slashingProposer) {
10
+ const rollupSlashingProposer = await rollup.getSlashingProposer();
11
+ if (!rollupSlashingProposer) {
12
+ throw new Error('Rollup slashing proposer not found');
13
+ }
14
+ slashingProposer = rollupSlashingProposer;
15
+ }
16
+
17
+ const [
18
+ slashingExecutionDelayInRounds,
19
+ slashingRoundSize,
20
+ slashingRoundSizeInEpochs,
21
+ slashingLifetimeInRounds,
22
+ slashingOffsetInRounds,
23
+ slashingAmounts,
24
+ slashingQuorumSize,
25
+ epochDuration,
26
+ l1GenesisTime,
27
+ slotDuration,
28
+ targetCommitteeSize,
29
+ ] = await Promise.all([
30
+ slashingProposer.getExecutionDelayInRounds(),
31
+ slashingProposer.getRoundSize(),
32
+ slashingProposer.getRoundSizeInEpochs(),
33
+ slashingProposer.getLifetimeInRounds(),
34
+ slashingProposer.getSlashOffsetInRounds(),
35
+ slashingProposer.getSlashingAmounts(),
36
+ slashingProposer.getQuorumSize(),
37
+ rollup.getEpochDuration(),
38
+ rollup.getL1GenesisTime(),
39
+ rollup.getSlotDuration(),
40
+ rollup.getTargetCommitteeSize(),
41
+ ]);
42
+
43
+ const settings: Omit<SlasherSettings, 'rollupRegisteredAtL2Slot'> = {
44
+ slashingExecutionDelayInRounds: Number(slashingExecutionDelayInRounds),
45
+ slashingRoundSize: Number(slashingRoundSize),
46
+ slashingRoundSizeInEpochs: Number(slashingRoundSizeInEpochs),
47
+ slashingLifetimeInRounds: Number(slashingLifetimeInRounds),
48
+ slashingQuorumSize: Number(slashingQuorumSize),
49
+ epochDuration: Number(epochDuration),
50
+ l1GenesisTime: l1GenesisTime,
51
+ slotDuration: Number(slotDuration),
52
+ slashingOffsetInRounds: Number(slashingOffsetInRounds),
53
+ slashingAmounts,
54
+ targetCommitteeSize: Number(targetCommitteeSize),
55
+ };
56
+
57
+ return settings;
58
+ }
@@ -0,0 +1,2 @@
1
+ export { createSlasherFacade as createSlasher } from './create_facade.js';
2
+ export { getSlasherSettings } from './get_settings.js';
@@ -0,0 +1,23 @@
1
+ // Auto-generated from spartan/environments/network-defaults.yml
2
+ // Do not edit manually - run yarn generate to regenerate
3
+
4
+ /** Default slasher configuration values from network-defaults.yml */
5
+ export const slasherDefaultEnv = {
6
+ SLASH_OFFENSE_EXPIRATION_ROUNDS: 4,
7
+ SLASH_MAX_PAYLOAD_SIZE: 80,
8
+ SLASH_EXECUTE_ROUNDS_LOOK_BACK: 4,
9
+ SLASH_DATA_WITHHOLDING_PENALTY: 10000000000000000000,
10
+ SLASH_DATA_WITHHOLDING_TOLERANCE_SLOTS: 3,
11
+ SLASH_INACTIVITY_TARGET_PERCENTAGE: 0.9,
12
+ SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD: 1,
13
+ SLASH_INACTIVITY_PENALTY: 10000000000000000000,
14
+ SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY: 10000000000000000000,
15
+ SLASH_PROPOSE_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS_PENALTY: 10000000000000000000,
16
+ SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY: 10000000000000000000,
17
+ SLASH_DUPLICATE_PROPOSAL_PENALTY: 0,
18
+ SLASH_DUPLICATE_ATTESTATION_PENALTY: 0,
19
+ SLASH_UNKNOWN_PENALTY: 10000000000000000000,
20
+ SLASH_INVALID_BLOCK_PENALTY: 10000000000000000000,
21
+ SLASH_INVALID_CHECKPOINT_PROPOSAL_PENALTY: 0,
22
+ SLASH_GRACE_PERIOD_L2_SLOTS: 0,
23
+ } as const;
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export * from './config.js';
2
+ export * from './watchers/data_withholding_watcher.js';
3
+ export * from './watchers/attestations_block_watcher.js';
4
+ export * from './watchers/attested_invalid_proposal_watcher.js';
5
+ export * from './watchers/broadcasted_invalid_checkpoint_proposal_watcher.js';
6
+ export * from './watchers/checkpoint_equivocation_watcher.js';
7
+ export * from './slasher_client.js';
8
+ export * from './slash_offenses_collector.js';
9
+ export * from './slasher_client_interface.js';
10
+ export * from './factory/index.js';
11
+ export * from './watcher.js';
12
+ export * from '@aztec-labs/stdlib/slashing';
package/src/metrics.ts ADDED
@@ -0,0 +1,58 @@
1
+ import {
2
+ type Gauge,
3
+ Metrics,
4
+ type TelemetryClient,
5
+ type UpDownCounter,
6
+ createUpDownCounterWithDefault,
7
+ } from '@aztec-labs/telemetry-client';
8
+ import { formatEther } from 'viem/utils';
9
+
10
+ export class SlasherMetrics {
11
+ private readonly roundExecuted: UpDownCounter;
12
+ private readonly ownValidatorTargeted: UpDownCounter;
13
+ private readonly ownValidatorSlashedCount: UpDownCounter;
14
+ private readonly ownValidatorSlashedAmount: UpDownCounter;
15
+ private readonly ownValidatorCurrentRoundVotesMax: Gauge;
16
+ private readonly quorumSize: Gauge;
17
+
18
+ constructor(client: TelemetryClient, name = 'Slasher') {
19
+ const meter = client.getMeter(name);
20
+ this.roundExecuted = createUpDownCounterWithDefault(meter, Metrics.SLASHER_ROUND_EXECUTED_COUNT);
21
+ this.ownValidatorTargeted = createUpDownCounterWithDefault(meter, Metrics.SLASHER_OWN_VALIDATOR_TARGETED_COUNT);
22
+ this.ownValidatorSlashedCount = createUpDownCounterWithDefault(meter, Metrics.SLASHER_OWN_VALIDATOR_SLASHED_COUNT);
23
+ this.ownValidatorSlashedAmount = createUpDownCounterWithDefault(
24
+ meter,
25
+ Metrics.SLASHER_OWN_VALIDATOR_SLASHED_AMOUNT,
26
+ );
27
+ this.ownValidatorCurrentRoundVotesMax = meter.createGauge(Metrics.SLASHER_OWN_VALIDATOR_CURRENT_ROUND_VOTES_MAX);
28
+ this.quorumSize = meter.createGauge(Metrics.SLASHER_QUORUM_SIZE);
29
+ }
30
+
31
+ public recordRoundExecuted(): void {
32
+ this.roundExecuted.add(1);
33
+ }
34
+
35
+ /** Records the quorum a validator must reach in a round to be slashed, so dashboards can plot the threshold. */
36
+ public recordQuorumSize(quorum: number): void {
37
+ this.quorumSize.record(quorum);
38
+ }
39
+
40
+ /** Records that an onchain slashing vote named one of the node's own validators as a target. */
41
+ public recordOwnValidatorTargeted(): void {
42
+ this.ownValidatorTargeted.add(1);
43
+ }
44
+
45
+ /**
46
+ * Records how close the most-voted committee position held by the node's own validators is to quorum this round.
47
+ * Recorded as an absolute value rather than a delta so a vote seen across a round rollover cannot make it drift.
48
+ */
49
+ public recordCurrentRoundVotesMax(votes: number): void {
50
+ this.ownValidatorCurrentRoundVotesMax.record(votes);
51
+ }
52
+
53
+ /** Records an executed slash against one of the node's own validators. */
54
+ public recordOwnValidatorSlashed(amount: bigint): void {
55
+ this.ownValidatorSlashedCount.add(1);
56
+ this.ownValidatorSlashedAmount.add(parseFloat(formatEther(amount)));
57
+ }
58
+ }
@@ -0,0 +1,37 @@
1
+ import type { SlotNumber } from '@aztec-labs/foundation/branded-types';
2
+ import type { Offense, ProposerSlashAction } from '@aztec-labs/stdlib/slashing';
3
+
4
+ import type { SlasherConfig } from './config.js';
5
+ import type { SlasherClientInterface } from './slasher_client_interface.js';
6
+
7
+ export class NullSlasherClient implements SlasherClientInterface {
8
+ constructor(private config: SlasherConfig) {}
9
+
10
+ public start(): Promise<void> {
11
+ return Promise.resolve();
12
+ }
13
+
14
+ public stop(): Promise<void> {
15
+ return Promise.resolve();
16
+ }
17
+
18
+ public gatherOffensesForRound(_round?: bigint): Promise<Offense[]> {
19
+ return Promise.resolve([]);
20
+ }
21
+
22
+ public getOffenses(): Promise<Offense[]> {
23
+ return Promise.resolve([]);
24
+ }
25
+
26
+ public updateConfig(config: Partial<SlasherConfig>): void {
27
+ this.config = { ...this.config, ...config };
28
+ }
29
+
30
+ public getProposerActions(_slotNumber: SlotNumber): Promise<ProposerSlashAction[]> {
31
+ return Promise.resolve([]);
32
+ }
33
+
34
+ public getConfig(): SlasherConfig {
35
+ return this.config;
36
+ }
37
+ }
@@ -0,0 +1,183 @@
1
+ import type { SlashVoteTarget, SlashingProposerContract } from '@aztec-labs/ethereum/contracts';
2
+ import { uniqueBy } from '@aztec-labs/foundation/collection';
3
+ import type { EthAddress } from '@aztec-labs/foundation/eth-address';
4
+ import { createLogger } from '@aztec-labs/foundation/log';
5
+ import { SerialQueue } from '@aztec-labs/foundation/queue';
6
+ import type { Hex } from 'viem';
7
+
8
+ import type { SlasherMetrics } from './metrics.js';
9
+
10
+ /**
11
+ * Watches slashing activity that targets the node's own validators: warns and counts on every vote naming one of
12
+ * them, tracks how close the current round's tally is to quorum, and reports executed slashes. Does nothing when
13
+ * the node runs no validators. Lifecycle calls must be sequential: start() before events are wired, and stop()
14
+ * awaited before any restart.
15
+ */
16
+ export class OwnValidatorSlashMonitor {
17
+ /**
18
+ * Tally for the single round being tracked (votes are only ever cast for the current round). `nextVoteIndex` is
19
+ * the cursor: the index of the first vote in the round not yet processed, advanced from the indices the VoteCast
20
+ * events carry. It is undefined until the first event of a round that started mid-round, whose own vote is then
21
+ * the first one counted, so votes cast before the node subscribed are never replayed into the cumulative metrics.
22
+ * The tally is per flattened committee position because that is the unit the contract tallies quorum by.
23
+ */
24
+ private state: { round: bigint; nextVoteIndex: bigint | undefined; countByPosition: Map<number, number> } = {
25
+ round: -1n,
26
+ nextVoteIndex: undefined,
27
+ countByPosition: new Map(),
28
+ };
29
+
30
+ /**
31
+ * All vote processing runs through this queue, one job at a time in arrival order. Serialization is what makes
32
+ * the cursor sound: without it, concurrent event handlers could process the same index twice or emit warnings
33
+ * with out-of-order running tallies. Replaced on every start(), since a queue that has been ended cannot run
34
+ * jobs again.
35
+ */
36
+ private queue = new SerialQueue();
37
+
38
+ private stopped = false;
39
+
40
+ constructor(
41
+ private readonly slashingProposer: SlashingProposerContract,
42
+ private readonly settings: { slashingQuorumSize: number },
43
+ private readonly ownValidators: EthAddress[],
44
+ private readonly metrics: SlasherMetrics,
45
+ private readonly log = createLogger('slasher:own-validators'),
46
+ ) {
47
+ this.queue.start();
48
+ }
49
+
50
+ private get enabled(): boolean {
51
+ return this.ownValidators.length > 0;
52
+ }
53
+
54
+ /**
55
+ * Starts tracking at the given round, leaving the cursor unset so the round's votes already cast are skipped:
56
+ * replaying them would warn about votes the operator can no longer react to sooner, and would double-count
57
+ * cumulative counters across restarts.
58
+ */
59
+ public start(currentRound: bigint): void {
60
+ if (!this.enabled) {
61
+ return;
62
+ }
63
+ this.stopped = false;
64
+ this.queue = new SerialQueue();
65
+ this.queue.start();
66
+ this.state = { round: currentRound, nextVoteIndex: undefined, countByPosition: new Map() };
67
+ this.metrics.recordQuorumSize(this.settings.slashingQuorumSize);
68
+ this.metrics.recordCurrentRoundVotesMax(0);
69
+ }
70
+
71
+ /** Stops processing and waits for any in-flight drain, so no warning or metric is emitted after shutdown. */
72
+ public async stop(): Promise<void> {
73
+ this.stopped = true;
74
+ // Jobs already queued are no-ops now that the flag is set, so end() runs through them at no cost while still
75
+ // settling the promises their callers hold, where cancel() would discard them and leave those promises pending.
76
+ await this.queue.end();
77
+ }
78
+
79
+ /**
80
+ * Called by the round monitor clock. Strictly greater so the clock catching up to a round an event already rolled
81
+ * to keeps the tally.
82
+ */
83
+ public handleNewRound(round: bigint): void {
84
+ if (this.enabled && round > this.state.round) {
85
+ this.rollTo(round);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Called on each VoteCast event with the index the event carries. Reads every vote from the cursor up to that
91
+ * index, so an event delivery that never arrived is healed by the next one.
92
+ */
93
+ public handleVoteCast(round: bigint, voteIndex: bigint): Promise<void> {
94
+ if (!this.enabled || this.stopped) {
95
+ return Promise.resolve();
96
+ }
97
+ // Errors are logged rather than propagated: callers fire and forget, and a failed read is retried by the drain
98
+ // the next event triggers.
99
+ return this.queue
100
+ .put(() => this.drainVotesTo(round, voteIndex))
101
+ .catch(err => this.log.error('Error processing slashing votes', err));
102
+ }
103
+
104
+ /** Reports executed slashes against own validators. Called with the Slashed events already fetched by the client. */
105
+ public handleSlashes(round: bigint, slashes: { attester: EthAddress; amount: bigint }[], l1BlockHash: Hex): void {
106
+ if (!this.enabled || this.stopped) {
107
+ return;
108
+ }
109
+ for (const { attester, amount } of slashes.filter(slash => this.isOwnValidator(slash.attester))) {
110
+ this.log.warn(`Own validator ${attester} was slashed for ${amount}`, {
111
+ round,
112
+ validator: attester.toString(),
113
+ amount,
114
+ l1BlockHash,
115
+ });
116
+ this.metrics.recordOwnValidatorSlashed(amount);
117
+ }
118
+ }
119
+
120
+ /** Processes votes [cursor, voteIndex] of a round, in order, advancing the cursor after each success. */
121
+ private async drainVotesTo(round: bigint, voteIndex: bigint): Promise<void> {
122
+ if (this.stopped) {
123
+ return; // enqueued before stop() but not yet started: emit nothing and read nothing after shutdown
124
+ }
125
+ if (round < this.state.round) {
126
+ return; // a vote for a round that already closed can no longer reach quorum
127
+ }
128
+ if (round > this.state.round) {
129
+ this.rollTo(round); // the event beat the round-monitor clock to the boundary
130
+ }
131
+
132
+ // The first event of a round tracked from mid-round counts its own vote only: earlier ones predate the subscription
133
+ const from = this.state.nextVoteIndex ?? voteIndex;
134
+ if (voteIndex < from) {
135
+ return; // a duplicate or out-of-order delivery of a vote already processed
136
+ }
137
+
138
+ for (let index = from; index <= voteIndex; index++) {
139
+ const vote = await this.slashingProposer.getVoteAt(round, index);
140
+ if (this.stopped || round !== this.state.round) {
141
+ return; // stopped, or rolled mid-drain: either way this vote must no longer be counted
142
+ }
143
+ this.processVote(round, vote);
144
+ // Advanced only after successful processing, so a failed read is retried on the next event
145
+ this.state.nextVoteIndex = index + 1n;
146
+ }
147
+ }
148
+
149
+ private processVote(round: bigint, vote: SlashVoteTarget[]): void {
150
+ const own = vote.filter(target => this.isOwnValidator(target.validator));
151
+ for (const { position } of own) {
152
+ this.state.countByPosition.set(position, (this.state.countByPosition.get(position) ?? 0) + 1);
153
+ }
154
+
155
+ // Warn once per (vote, validator) at the validator's highest position tally: the operator cares about the
156
+ // validator, not which of its committee seats is being voted on. No slash amount (the amount voted is not
157
+ // necessarily the amount slashed) and no proposer (a catch-up drain cannot attribute votes to proposers).
158
+ const quorum = this.settings.slashingQuorumSize;
159
+ for (const { validator } of uniqueBy(own, target => target.validator.toString())) {
160
+ const votes = Math.max(
161
+ ...own.filter(t => t.validator.equals(validator)).map(t => this.state.countByPosition.get(t.position)!),
162
+ );
163
+ this.metrics.recordOwnValidatorTargeted();
164
+ this.log.warn(
165
+ `Own validator ${validator} targeted by slashing vote (${votes} of ${quorum} votes needed to slash)`,
166
+ { round, validator: validator.toString(), votes, quorum },
167
+ );
168
+ }
169
+
170
+ this.metrics.recordCurrentRoundVotesMax(Math.max(0, ...this.state.countByPosition.values()));
171
+ }
172
+
173
+ /** Starts a fresh tally, zeroing the gauge so a quiet round does not keep the previous round's value. */
174
+ private rollTo(round: bigint): void {
175
+ this.state = { round, nextVoteIndex: 0n, countByPosition: new Map() };
176
+ this.metrics.recordCurrentRoundVotesMax(0);
177
+ this.metrics.recordQuorumSize(this.settings.slashingQuorumSize); // keeps the gauge fresh across export cycles
178
+ }
179
+
180
+ private isOwnValidator(address: EthAddress): boolean {
181
+ return this.ownValidators.some(validator => validator.equals(address));
182
+ }
183
+ }
@@ -0,0 +1,156 @@
1
+ import type { SlotNumber } from '@aztec-labs/foundation/branded-types';
2
+ import { createLogger } from '@aztec-labs/foundation/log';
3
+ import { SerialQueue } from '@aztec-labs/foundation/queue';
4
+ import type { Prettify } from '@aztec-labs/foundation/types';
5
+ import type { L1RollupConstants } from '@aztec-labs/stdlib/epoch-helpers';
6
+ import type { SlasherConfig } from '@aztec-labs/stdlib/interfaces/server';
7
+ import { type Offense, getOffenseTypeName, getSlotForOffense } from '@aztec-labs/stdlib/slashing';
8
+
9
+ import type { SlasherOffensesStore } from './stores/offenses_store.js';
10
+ import {
11
+ WANT_TO_CLEAR_SLASH_EVENT,
12
+ WANT_TO_SLASH_EVENT,
13
+ type WantToClearSlashArgs,
14
+ type WantToSlashArgs,
15
+ type Watcher,
16
+ } from './watcher.js';
17
+
18
+ export type SlashOffensesCollectorConfig = Prettify<Pick<SlasherConfig, 'slashGracePeriodL2Slots'>>;
19
+ export type SlashOffensesCollectorSettings = Prettify<
20
+ Pick<L1RollupConstants, 'epochDuration'> & {
21
+ slashingAmounts: [bigint, bigint, bigint] | undefined;
22
+ /** L2 slot at which the rollup was registered as canonical in the Registry. Used to anchor the slash grace period. */
23
+ rollupRegisteredAtL2Slot: SlotNumber;
24
+ }
25
+ >;
26
+
27
+ /**
28
+ * Collects and manages slashable offenses from watchers.
29
+ * This class handles the common logic for subscribing to slash watcher events,
30
+ * storing offenses, and retrieving pending offenses for slashing.
31
+ */
32
+ export class SlashOffensesCollector {
33
+ private readonly unwatchCallbacks: (() => void)[] = [];
34
+ private readonly storeMutationQueue = new SerialQueue();
35
+
36
+ constructor(
37
+ private readonly config: SlashOffensesCollectorConfig,
38
+ private readonly settings: SlashOffensesCollectorSettings,
39
+ private readonly watchers: Watcher[],
40
+ private readonly offensesStore: SlasherOffensesStore,
41
+ private readonly log = createLogger('slasher:offenses-collector'),
42
+ ) {}
43
+
44
+ public start() {
45
+ this.log.debug('Starting SlashOffensesCollector...');
46
+ this.storeMutationQueue.start();
47
+
48
+ // Subscribe to watcher slashing events.
49
+ for (const watcher of this.watchers) {
50
+ const wantToSlashCallback = (args: WantToSlashArgs[]) =>
51
+ this.enqueueStoreMutation('wantToSlash', () => this.handleWantToSlash(args));
52
+ watcher.on(WANT_TO_SLASH_EVENT, wantToSlashCallback);
53
+ this.unwatchCallbacks.push(() => watcher.removeListener(WANT_TO_SLASH_EVENT, wantToSlashCallback));
54
+
55
+ const wantToClearSlashCallback = (args: WantToClearSlashArgs[]) =>
56
+ this.enqueueStoreMutation('wantToClearSlash', () => this.handleWantToClearSlash(args));
57
+ watcher.on(WANT_TO_CLEAR_SLASH_EVENT, wantToClearSlashCallback);
58
+ this.unwatchCallbacks.push(() => watcher.removeListener(WANT_TO_CLEAR_SLASH_EVENT, wantToClearSlashCallback));
59
+ }
60
+
61
+ this.log.info('Started SlashOffensesCollector');
62
+ return Promise.resolve();
63
+ }
64
+
65
+ public async stop() {
66
+ this.log.debug('Stopping SlashOffensesCollector...');
67
+
68
+ for (const unwatchCallback of this.unwatchCallbacks) {
69
+ unwatchCallback();
70
+ }
71
+
72
+ await this.storeMutationQueue.end();
73
+
74
+ this.log.info('SlashOffensesCollector stopped');
75
+ }
76
+
77
+ /**
78
+ * Called when a slash watcher emits WANT_TO_SLASH_EVENT.
79
+ * Stores pending offenses instead of creating payloads immediately.
80
+ * @param args - the arguments from the watcher, including the validators, amounts, and offenses
81
+ */
82
+ public async handleWantToSlash(args: WantToSlashArgs[]) {
83
+ for (const arg of args) {
84
+ const offense: Offense = {
85
+ validator: arg.validator,
86
+ amount: arg.amount,
87
+ offenseType: arg.offenseType,
88
+ epochOrSlot: arg.epochOrSlot,
89
+ };
90
+
91
+ if (this.shouldSkipOffense(offense)) {
92
+ this.log.verbose('Skipping offense during grace period', this.getOffenseLogData(offense));
93
+ continue;
94
+ }
95
+
96
+ const added = await this.offensesStore.addOffense(offense);
97
+ if (added) {
98
+ if (this.settings.slashingAmounts) {
99
+ const minSlash = this.settings.slashingAmounts[0];
100
+ if (arg.amount < minSlash) {
101
+ this.log.warn(
102
+ `Offense amount ${arg.amount} is below minimum slashing amount ${minSlash}`,
103
+ this.getOffenseLogData(offense),
104
+ );
105
+ }
106
+ }
107
+
108
+ this.log.info(`Adding pending offense for validator ${arg.validator}`, this.getOffenseLogData(offense));
109
+ } else {
110
+ this.log.debug('Skipping repeated offense', this.getOffenseLogData(offense));
111
+ }
112
+ }
113
+ }
114
+
115
+ public async handleWantToClearSlash(args: WantToClearSlashArgs[]) {
116
+ for (const arg of args) {
117
+ const cleared = await this.offensesStore.clearOffenses(arg);
118
+ if (cleared > 0) {
119
+ this.log.info(`Cleared ${cleared} pending offenses`, {
120
+ offenseType: getOffenseTypeName(arg.offenseType),
121
+ epochOrSlot: arg.epochOrSlot,
122
+ validators: arg.validators?.map(validator => validator.toString()),
123
+ });
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Triggered on a time basis when we enter a new slashing round.
130
+ * Clears expired offenses from stores.
131
+ */
132
+ public async handleNewRound(round: bigint) {
133
+ const cleared = await this.offensesStore.clearExpiredOffenses(round);
134
+ if (cleared && cleared > 0) {
135
+ this.log.debug(`Cleared ${cleared} expired offenses for round ${round}`);
136
+ }
137
+ }
138
+
139
+ /** Returns whether to skip an offense if it happened during the grace period after the network upgrade */
140
+ private shouldSkipOffense(offense: Offense): boolean {
141
+ const offenseSlot = getSlotForOffense(offense, this.settings);
142
+ return offenseSlot < this.settings.rollupRegisteredAtL2Slot + this.config.slashGracePeriodL2Slots;
143
+ }
144
+
145
+ private getOffenseLogData(offense: Offense) {
146
+ return {
147
+ ...offense,
148
+ validator: offense.validator.toString(),
149
+ offenseType: getOffenseTypeName(offense.offenseType),
150
+ };
151
+ }
152
+
153
+ private enqueueStoreMutation(label: string, callback: () => Promise<void>) {
154
+ void this.storeMutationQueue.put(callback).catch(err => this.log.error(`Error handling ${label}`, err));
155
+ }
156
+ }
@@ -0,0 +1,62 @@
1
+ import { SlotNumber } from '@aztec-labs/foundation/branded-types';
2
+ import { createLogger } from '@aztec-labs/foundation/log';
3
+ import type { DateProvider } from '@aztec-labs/foundation/timer';
4
+ import type { Prettify } from '@aztec-labs/foundation/types';
5
+ import { type L1RollupConstants, getSlotAtTimestamp } from '@aztec-labs/stdlib/epoch-helpers';
6
+ import { getRoundForSlot } from '@aztec-labs/stdlib/slashing';
7
+
8
+ export type SlashRoundMonitorSettings = Prettify<
9
+ Pick<L1RollupConstants, 'epochDuration' | 'l1GenesisTime' | 'slotDuration'> & { slashingRoundSize: number }
10
+ >;
11
+
12
+ export class SlashRoundMonitor {
13
+ private currentRound: bigint = 0n;
14
+ private intervalId: NodeJS.Timeout | undefined = undefined;
15
+ private handler: ((round: bigint) => Promise<void>) | undefined = undefined;
16
+
17
+ constructor(
18
+ private settings: SlashRoundMonitorSettings,
19
+ private dateProvider: DateProvider,
20
+ private log = createLogger('slasher:round-monitor'),
21
+ ) {}
22
+
23
+ public start() {
24
+ // Check for round changes
25
+ this.currentRound = this.getCurrentRound().round;
26
+ this.intervalId = setInterval(() => {
27
+ const round = this.getCurrentRound().round;
28
+ if (round !== this.currentRound) {
29
+ this.currentRound = round;
30
+ if (this.handler) {
31
+ void this.handler(round).catch(err => this.log.error('Error handling new round', err));
32
+ }
33
+ }
34
+ }, 500);
35
+ }
36
+
37
+ public stop() {
38
+ if (this.intervalId) {
39
+ clearInterval(this.intervalId);
40
+ this.intervalId = undefined;
41
+ }
42
+ }
43
+
44
+ public listenToNewRound(handler: (round: bigint) => Promise<void>): () => void {
45
+ this.handler = handler;
46
+ return () => {
47
+ this.handler = undefined;
48
+ };
49
+ }
50
+
51
+ /** Returns the slashing round number and the voting slot within the round based on the L2 chain slot */
52
+ public getRoundForSlot(slotNumber: SlotNumber): { round: bigint; votingSlot: SlotNumber } {
53
+ return getRoundForSlot(slotNumber, this.settings);
54
+ }
55
+
56
+ /** Returns the current slashing round and voting slot within the round */
57
+ public getCurrentRound(): { round: bigint; votingSlot: SlotNumber } {
58
+ const now = this.dateProvider.nowInSeconds();
59
+ const currentSlot = getSlotAtTimestamp(BigInt(now), this.settings);
60
+ return this.getRoundForSlot(currentSlot);
61
+ }
62
+ }