@aztec/slasher 0.0.1-commit.f2ce05ee → 0.0.1-commit.f5a9928

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 (100) hide show
  1. package/README.md +85 -78
  2. package/dest/config.d.ts +1 -1
  3. package/dest/config.d.ts.map +1 -1
  4. package/dest/config.js +35 -29
  5. package/dest/factory/create_facade.d.ts +7 -4
  6. package/dest/factory/create_facade.d.ts.map +1 -1
  7. package/dest/factory/create_facade.js +29 -3
  8. package/dest/factory/create_implementation.d.ts +7 -7
  9. package/dest/factory/create_implementation.d.ts.map +1 -1
  10. package/dest/factory/create_implementation.js +8 -56
  11. package/dest/factory/get_settings.d.ts +4 -4
  12. package/dest/factory/get_settings.d.ts.map +1 -1
  13. package/dest/factory/get_settings.js +3 -3
  14. package/dest/factory/index.d.ts +2 -2
  15. package/dest/factory/index.d.ts.map +1 -1
  16. package/dest/factory/index.js +1 -1
  17. package/dest/generated/slasher-defaults.d.ts +8 -7
  18. package/dest/generated/slasher-defaults.d.ts.map +1 -1
  19. package/dest/generated/slasher-defaults.js +7 -6
  20. package/dest/index.d.ts +6 -4
  21. package/dest/index.d.ts.map +1 -1
  22. package/dest/index.js +5 -3
  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 +3 -4
  27. package/dest/null_slasher_client.d.ts.map +1 -1
  28. package/dest/null_slasher_client.js +1 -4
  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 +172 -0
  32. package/dest/slash_offenses_collector.d.ts +10 -9
  33. package/dest/slash_offenses_collector.d.ts.map +1 -1
  34. package/dest/slash_offenses_collector.js +50 -34
  35. package/dest/slasher_client.d.ts +118 -0
  36. package/dest/slasher_client.d.ts.map +1 -0
  37. package/dest/{tally_slasher_client.js → slasher_client.js} +65 -45
  38. package/dest/slasher_client_facade.d.ts +8 -8
  39. package/dest/slasher_client_facade.d.ts.map +1 -1
  40. package/dest/slasher_client_facade.js +8 -9
  41. package/dest/slasher_client_interface.d.ts +7 -21
  42. package/dest/slasher_client_interface.d.ts.map +1 -1
  43. package/dest/slasher_client_interface.js +1 -4
  44. package/dest/stores/offenses_store.d.ts +12 -12
  45. package/dest/stores/offenses_store.d.ts.map +1 -1
  46. package/dest/stores/offenses_store.js +61 -38
  47. package/dest/watcher.d.ts +8 -1
  48. package/dest/watcher.d.ts.map +1 -1
  49. package/dest/watcher.js +1 -0
  50. package/dest/watchers/attestations_block_watcher.d.ts +26 -13
  51. package/dest/watchers/attestations_block_watcher.d.ts.map +1 -1
  52. package/dest/watchers/attestations_block_watcher.js +76 -61
  53. package/dest/watchers/attested_invalid_proposal_watcher.d.ts +42 -0
  54. package/dest/watchers/attested_invalid_proposal_watcher.d.ts.map +1 -0
  55. package/dest/watchers/attested_invalid_proposal_watcher.js +117 -0
  56. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts +38 -0
  57. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts.map +1 -0
  58. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.js +138 -0
  59. package/dest/watchers/checkpoint_equivocation_watcher.d.ts +30 -0
  60. package/dest/watchers/checkpoint_equivocation_watcher.d.ts.map +1 -0
  61. package/dest/watchers/checkpoint_equivocation_watcher.js +69 -0
  62. package/dest/watchers/data_withholding_watcher.d.ts +69 -0
  63. package/dest/watchers/data_withholding_watcher.d.ts.map +1 -0
  64. package/dest/watchers/data_withholding_watcher.js +223 -0
  65. package/package.json +10 -10
  66. package/src/config.ts +42 -29
  67. package/src/factory/create_facade.ts +37 -5
  68. package/src/factory/create_implementation.ts +29 -105
  69. package/src/factory/get_settings.ts +8 -8
  70. package/src/factory/index.ts +1 -1
  71. package/src/generated/slasher-defaults.ts +7 -6
  72. package/src/index.ts +5 -3
  73. package/src/metrics.ts +59 -0
  74. package/src/null_slasher_client.ts +2 -6
  75. package/src/own_validator_slash_monitor.ts +195 -0
  76. package/src/slash_offenses_collector.ts +70 -36
  77. package/src/{tally_slasher_client.ts → slasher_client.ts} +84 -54
  78. package/src/slasher_client_facade.ts +9 -11
  79. package/src/slasher_client_interface.ts +6 -21
  80. package/src/stores/offenses_store.ts +73 -47
  81. package/src/watcher.ts +8 -0
  82. package/src/watchers/attestations_block_watcher.ts +88 -82
  83. package/src/watchers/attested_invalid_proposal_watcher.ts +168 -0
  84. package/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts +192 -0
  85. package/src/watchers/checkpoint_equivocation_watcher.ts +96 -0
  86. package/src/watchers/data_withholding_watcher.ts +257 -0
  87. package/dest/empire_slasher_client.d.ts +0 -190
  88. package/dest/empire_slasher_client.d.ts.map +0 -1
  89. package/dest/empire_slasher_client.js +0 -564
  90. package/dest/stores/payloads_store.d.ts +0 -29
  91. package/dest/stores/payloads_store.d.ts.map +0 -1
  92. package/dest/stores/payloads_store.js +0 -128
  93. package/dest/tally_slasher_client.d.ts +0 -125
  94. package/dest/tally_slasher_client.d.ts.map +0 -1
  95. package/dest/watchers/epoch_prune_watcher.d.ts +0 -39
  96. package/dest/watchers/epoch_prune_watcher.d.ts.map +0 -1
  97. package/dest/watchers/epoch_prune_watcher.js +0 -175
  98. package/src/empire_slasher_client.ts +0 -649
  99. package/src/stores/payloads_store.ts +0 -149
  100. package/src/watchers/epoch_prune_watcher.ts +0 -251
@@ -1,142 +1,65 @@
1
1
  import { EpochCache } from '@aztec/epoch-cache';
2
- import {
3
- EmpireSlashingProposerContract,
4
- RollupContract,
5
- TallySlashingProposerContract,
6
- } from '@aztec/ethereum/contracts';
2
+ import { RollupContract, SlashingProposerContract } from '@aztec/ethereum/contracts';
7
3
  import type { ViemClient } from '@aztec/ethereum/types';
8
- import { EthAddress } from '@aztec/foundation/eth-address';
4
+ import type { SlotNumber } from '@aztec/foundation/branded-types';
5
+ import type { EthAddress } from '@aztec/foundation/eth-address';
9
6
  import { createLogger } from '@aztec/foundation/log';
10
7
  import { DateProvider } from '@aztec/foundation/timer';
11
- import type { DataStoreConfig } from '@aztec/kv-store/config';
12
8
  import { AztecLMDBStoreV2 } from '@aztec/kv-store/lmdb-v2';
13
9
  import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
14
- import { SlashFactoryContract } from '@aztec/stdlib/l1-contracts';
10
+ import type { DataStoreConfig } from '@aztec/stdlib/kv-store';
15
11
 
16
- import { EmpireSlasherClient, type EmpireSlasherSettings } from '../empire_slasher_client.js';
17
12
  import { NullSlasherClient } from '../null_slasher_client.js';
13
+ import { SlasherClient } from '../slasher_client.js';
18
14
  import { SlasherOffensesStore } from '../stores/offenses_store.js';
19
- import { SlasherPayloadsStore } from '../stores/payloads_store.js';
20
- import { TallySlasherClient } from '../tally_slasher_client.js';
21
15
  import type { Watcher } from '../watcher.js';
22
- import { getTallySlasherSettings } from './get_settings.js';
16
+ import { getSlasherSettings } from './get_settings.js';
23
17
 
24
- /** Creates a slasher client implementation (either tally or empire) based on the slasher proposer type in the rollup */
18
+ /** Creates a slasher client implementation based on the slasher proposer type in the rollup */
25
19
  export async function createSlasherImplementation(
26
20
  config: SlasherConfig & DataStoreConfig & { ethereumSlotDuration: number },
27
21
  rollup: RollupContract,
28
22
  l1Client: ViemClient,
29
- slashFactoryAddress: EthAddress | undefined,
30
23
  watchers: Watcher[],
31
24
  epochCache: EpochCache,
32
25
  dateProvider: DateProvider,
33
26
  kvStore: AztecLMDBStoreV2,
27
+ rollupRegisteredAtL2Slot: SlotNumber,
28
+ ownValidators: EthAddress[] = [],
34
29
  logger = createLogger('slasher'),
35
30
  ) {
36
31
  const proposer = await rollup.getSlashingProposer();
37
32
  if (!proposer) {
38
33
  return new NullSlasherClient(config);
39
- } else if (proposer.type === 'tally') {
40
- return createTallySlasher(config, rollup, proposer, watchers, dateProvider, epochCache, kvStore, logger);
41
34
  } else {
42
- if (!slashFactoryAddress || slashFactoryAddress.equals(EthAddress.ZERO)) {
43
- throw new Error('Cannot initialize an empire-based SlasherClient without a SlashFactory address');
44
- }
45
- const slashFactory = new SlashFactoryContract(l1Client, slashFactoryAddress.toString());
46
- return createEmpireSlasher(config, rollup, proposer, slashFactory, watchers, dateProvider, kvStore, logger);
35
+ return createSlasher(
36
+ config,
37
+ rollup,
38
+ proposer,
39
+ watchers,
40
+ dateProvider,
41
+ epochCache,
42
+ kvStore,
43
+ rollupRegisteredAtL2Slot,
44
+ ownValidators,
45
+ logger,
46
+ );
47
47
  }
48
48
  }
49
49
 
50
- async function createEmpireSlasher(
51
- config: SlasherConfig & DataStoreConfig & { ethereumSlotDuration: number },
52
- rollup: RollupContract,
53
- slashingProposer: EmpireSlashingProposerContract,
54
- slashFactoryContract: SlashFactoryContract,
55
- watchers: Watcher[],
56
- dateProvider: DateProvider,
57
- kvStore: AztecLMDBStoreV2,
58
- logger = createLogger('slasher'),
59
- ): Promise<EmpireSlasherClient> {
60
- if (slashingProposer.type !== 'empire') {
61
- throw new Error('Slashing proposer contract is not of type Empire');
62
- }
63
-
64
- const [
65
- slashingExecutionDelayInRounds,
66
- slashingPayloadLifetimeInRounds,
67
- slashingRoundSize,
68
- slashingQuorumSize,
69
- epochDuration,
70
- proofSubmissionEpochs,
71
- l1GenesisTime,
72
- slotDuration,
73
- l1StartBlock,
74
- slasher,
75
- ] = await Promise.all([
76
- slashingProposer.getExecutionDelayInRounds(),
77
- slashingProposer.getLifetimeInRounds(),
78
- slashingProposer.getRoundSize(),
79
- slashingProposer.getQuorumSize(),
80
- rollup.getEpochDuration(),
81
- rollup.getProofSubmissionEpochs(),
82
- rollup.getL1GenesisTime(),
83
- rollup.getSlotDuration(),
84
- rollup.getL1StartBlock(),
85
- rollup.getSlasherContract(),
86
- ]);
87
-
88
- const settings: EmpireSlasherSettings = {
89
- slashingExecutionDelayInRounds: Number(slashingExecutionDelayInRounds),
90
- slashingPayloadLifetimeInRounds: Number(slashingPayloadLifetimeInRounds),
91
- slashingRoundSize: Number(slashingRoundSize),
92
- slashingQuorumSize: Number(slashingQuorumSize),
93
- epochDuration: Number(epochDuration),
94
- proofSubmissionEpochs: Number(proofSubmissionEpochs),
95
- l1GenesisTime: l1GenesisTime,
96
- slotDuration: Number(slotDuration),
97
- l1StartBlock,
98
- ethereumSlotDuration: config.ethereumSlotDuration,
99
- slashingAmounts: undefined,
100
- };
101
-
102
- const payloadsStore = new SlasherPayloadsStore(kvStore, {
103
- slashingPayloadLifetimeInRounds: settings.slashingPayloadLifetimeInRounds,
104
- });
105
- const offensesStore = new SlasherOffensesStore(kvStore, {
106
- ...settings,
107
- slashOffenseExpirationRounds: config.slashOffenseExpirationRounds,
108
- });
109
-
110
- return new EmpireSlasherClient(
111
- config,
112
- settings,
113
- slashFactoryContract,
114
- slashingProposer,
115
- slasher!,
116
- rollup,
117
- watchers,
118
- dateProvider,
119
- offensesStore,
120
- payloadsStore,
121
- logger,
122
- );
123
- }
124
-
125
- async function createTallySlasher(
50
+ async function createSlasher(
126
51
  config: SlasherConfig & DataStoreConfig,
127
52
  rollup: RollupContract,
128
- slashingProposer: TallySlashingProposerContract,
53
+ slashingProposer: SlashingProposerContract,
129
54
  watchers: Watcher[],
130
55
  dateProvider: DateProvider,
131
56
  epochCache: EpochCache,
132
57
  kvStore: AztecLMDBStoreV2,
58
+ rollupRegisteredAtL2Slot: SlotNumber,
59
+ ownValidators: EthAddress[] = [],
133
60
  logger = createLogger('slasher'),
134
- ): Promise<TallySlasherClient> {
135
- if (slashingProposer.type !== 'tally') {
136
- throw new Error('Slashing proposer contract is not of type tally');
137
- }
138
-
139
- const settings = await getTallySlasherSettings(rollup, slashingProposer);
61
+ ): Promise<SlasherClient> {
62
+ const settings = { ...(await getSlasherSettings(rollup, slashingProposer)), rollupRegisteredAtL2Slot };
140
63
  const slasher = await rollup.getSlasherContract();
141
64
 
142
65
  const offensesStore = new SlasherOffensesStore(kvStore, {
@@ -144,7 +67,7 @@ async function createTallySlasher(
144
67
  slashOffenseExpirationRounds: config.slashOffenseExpirationRounds,
145
68
  });
146
69
 
147
- return new TallySlasherClient(
70
+ return new SlasherClient(
148
71
  config,
149
72
  settings,
150
73
  slashingProposer,
@@ -154,6 +77,7 @@ async function createTallySlasher(
154
77
  epochCache,
155
78
  dateProvider,
156
79
  offensesStore,
80
+ ownValidators,
157
81
  logger,
158
82
  );
159
83
  }
@@ -1,15 +1,15 @@
1
- import type { RollupContract, TallySlashingProposerContract } from '@aztec/ethereum/contracts';
1
+ import type { RollupContract, SlashingProposerContract } from '@aztec/ethereum/contracts';
2
2
 
3
- import type { TallySlasherSettings } from '../tally_slasher_client.js';
3
+ import type { SlasherSettings } from '../slasher_client.js';
4
4
 
5
- export async function getTallySlasherSettings(
5
+ export async function getSlasherSettings(
6
6
  rollup: RollupContract,
7
- slashingProposer?: TallySlashingProposerContract,
8
- ): Promise<TallySlasherSettings> {
7
+ slashingProposer?: SlashingProposerContract,
8
+ ): Promise<Omit<SlasherSettings, 'rollupRegisteredAtL2Slot'>> {
9
9
  if (!slashingProposer) {
10
10
  const rollupSlashingProposer = await rollup.getSlashingProposer();
11
- if (!rollupSlashingProposer || rollupSlashingProposer.type !== 'tally') {
12
- throw new Error('Rollup slashing proposer is not of type tally');
11
+ if (!rollupSlashingProposer) {
12
+ throw new Error('Rollup slashing proposer not found');
13
13
  }
14
14
  slashingProposer = rollupSlashingProposer;
15
15
  }
@@ -40,7 +40,7 @@ export async function getTallySlasherSettings(
40
40
  rollup.getTargetCommitteeSize(),
41
41
  ]);
42
42
 
43
- const settings: TallySlasherSettings = {
43
+ const settings: Omit<SlasherSettings, 'rollupRegisteredAtL2Slot'> = {
44
44
  slashingExecutionDelayInRounds: Number(slashingExecutionDelayInRounds),
45
45
  slashingRoundSize: Number(slashingRoundSize),
46
46
  slashingRoundSizeInEpochs: Number(slashingRoundSizeInEpochs),
@@ -1,2 +1,2 @@
1
1
  export { createSlasherFacade as createSlasher } from './create_facade.js';
2
- export { getTallySlasherSettings } from './get_settings.js';
2
+ export { getSlasherSettings } from './get_settings.js';
@@ -3,20 +3,21 @@
3
3
 
4
4
  /** Default slasher configuration values from network-defaults.yml */
5
5
  export const slasherDefaultEnv = {
6
- SLASH_MIN_PENALTY_PERCENTAGE: 0.5,
7
- SLASH_MAX_PENALTY_PERCENTAGE: 2,
8
6
  SLASH_OFFENSE_EXPIRATION_ROUNDS: 4,
9
- SLASH_MAX_PAYLOAD_SIZE: 50,
7
+ SLASH_MAX_PAYLOAD_SIZE: 80,
10
8
  SLASH_EXECUTE_ROUNDS_LOOK_BACK: 4,
11
- SLASH_PRUNE_PENALTY: 10000000000000000000,
12
9
  SLASH_DATA_WITHHOLDING_PENALTY: 10000000000000000000,
10
+ SLASH_DATA_WITHHOLDING_TOLERANCE_SLOTS: 3,
13
11
  SLASH_INACTIVITY_TARGET_PERCENTAGE: 0.9,
14
12
  SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD: 1,
15
13
  SLASH_INACTIVITY_PENALTY: 10000000000000000000,
16
14
  SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY: 10000000000000000000,
17
- SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 10000000000000000000,
18
- SLASH_DUPLICATE_PROPOSAL_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
19
  SLASH_UNKNOWN_PENALTY: 10000000000000000000,
20
20
  SLASH_INVALID_BLOCK_PENALTY: 10000000000000000000,
21
+ SLASH_INVALID_CHECKPOINT_PROPOSAL_PENALTY: 0,
21
22
  SLASH_GRACE_PERIOD_L2_SLOTS: 0,
22
23
  } as const;
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export * from './config.js';
2
- export * from './watchers/epoch_prune_watcher.js';
2
+ export * from './watchers/data_withholding_watcher.js';
3
3
  export * from './watchers/attestations_block_watcher.js';
4
- export * from './empire_slasher_client.js';
5
- export * from './tally_slasher_client.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';
6
8
  export * from './slash_offenses_collector.js';
7
9
  export * from './slasher_client_interface.js';
8
10
  export * from './factory/index.js';
package/src/metrics.ts ADDED
@@ -0,0 +1,59 @@
1
+ import {
2
+ type Gauge,
3
+ Metrics,
4
+ type TelemetryClient,
5
+ type UpDownCounter,
6
+ createUpDownCounterWithDefault,
7
+ } from '@aztec/telemetry-client';
8
+
9
+ import { formatEther } from 'viem/utils';
10
+
11
+ export class SlasherMetrics {
12
+ private readonly roundExecuted: UpDownCounter;
13
+ private readonly ownValidatorTargeted: UpDownCounter;
14
+ private readonly ownValidatorSlashedCount: UpDownCounter;
15
+ private readonly ownValidatorSlashedAmount: UpDownCounter;
16
+ private readonly ownValidatorCurrentRoundVotesMax: Gauge;
17
+ private readonly quorumSize: Gauge;
18
+
19
+ constructor(client: TelemetryClient, name = 'Slasher') {
20
+ const meter = client.getMeter(name);
21
+ this.roundExecuted = createUpDownCounterWithDefault(meter, Metrics.SLASHER_ROUND_EXECUTED_COUNT);
22
+ this.ownValidatorTargeted = createUpDownCounterWithDefault(meter, Metrics.SLASHER_OWN_VALIDATOR_TARGETED_COUNT);
23
+ this.ownValidatorSlashedCount = createUpDownCounterWithDefault(meter, Metrics.SLASHER_OWN_VALIDATOR_SLASHED_COUNT);
24
+ this.ownValidatorSlashedAmount = createUpDownCounterWithDefault(
25
+ meter,
26
+ Metrics.SLASHER_OWN_VALIDATOR_SLASHED_AMOUNT,
27
+ );
28
+ this.ownValidatorCurrentRoundVotesMax = meter.createGauge(Metrics.SLASHER_OWN_VALIDATOR_CURRENT_ROUND_VOTES_MAX);
29
+ this.quorumSize = meter.createGauge(Metrics.SLASHER_QUORUM_SIZE);
30
+ }
31
+
32
+ public recordRoundExecuted(): void {
33
+ this.roundExecuted.add(1);
34
+ }
35
+
36
+ /** Records the quorum a validator must reach in a round to be slashed, so dashboards can plot the threshold. */
37
+ public recordQuorumSize(quorum: number): void {
38
+ this.quorumSize.record(quorum);
39
+ }
40
+
41
+ /** Records that an onchain slashing vote named one of the node's own validators as a target. */
42
+ public recordOwnValidatorTargeted(): void {
43
+ this.ownValidatorTargeted.add(1);
44
+ }
45
+
46
+ /**
47
+ * Records how close the most-voted committee position held by the node's own validators is to quorum this round.
48
+ * Recorded as an absolute value rather than a delta so a vote seen across a round rollover cannot make it drift.
49
+ */
50
+ public recordCurrentRoundVotesMax(votes: number): void {
51
+ this.ownValidatorCurrentRoundVotesMax.record(votes);
52
+ }
53
+
54
+ /** Records an executed slash against one of the node's own validators. */
55
+ public recordOwnValidatorSlashed(amount: bigint): void {
56
+ this.ownValidatorSlashedCount.add(1);
57
+ this.ownValidatorSlashedAmount.add(parseFloat(formatEther(amount)));
58
+ }
59
+ }
@@ -1,5 +1,5 @@
1
1
  import type { SlotNumber } from '@aztec/foundation/branded-types';
2
- import type { Offense, ProposerSlashAction, SlashPayloadRound } from '@aztec/stdlib/slashing';
2
+ import type { Offense, ProposerSlashAction } from '@aztec/stdlib/slashing';
3
3
 
4
4
  import type { SlasherConfig } from './config.js';
5
5
  import type { SlasherClientInterface } from './slasher_client_interface.js';
@@ -15,15 +15,11 @@ export class NullSlasherClient implements SlasherClientInterface {
15
15
  return Promise.resolve();
16
16
  }
17
17
 
18
- public getSlashPayloads(): Promise<SlashPayloadRound[]> {
19
- return Promise.resolve([]);
20
- }
21
-
22
18
  public gatherOffensesForRound(_round?: bigint): Promise<Offense[]> {
23
19
  return Promise.resolve([]);
24
20
  }
25
21
 
26
- public getPendingOffenses(): Promise<Offense[]> {
22
+ public getOffenses(): Promise<Offense[]> {
27
23
  return Promise.resolve([]);
28
24
  }
29
25
 
@@ -0,0 +1,195 @@
1
+ import type { SlashVoteTarget, SlashingProposerContract } from '@aztec/ethereum/contracts';
2
+ import { maxBigint } from '@aztec/foundation/bigint';
3
+ import { uniqueBy } from '@aztec/foundation/collection';
4
+ import type { EthAddress } from '@aztec/foundation/eth-address';
5
+ import { createLogger } from '@aztec/foundation/log';
6
+ import { SerialQueue } from '@aztec/foundation/queue';
7
+
8
+ import type { Hex } from 'viem';
9
+
10
+ import type { SlasherMetrics } from './metrics.js';
11
+
12
+ /**
13
+ * Watches slashing activity that targets the node's own validators: warns and counts on every vote naming one of
14
+ * them, tracks how close the current round's tally is to quorum, and reports executed slashes. Does nothing when
15
+ * the node runs no validators. Lifecycle calls must be sequential: start() awaited before events are wired, and
16
+ * stop() awaited before any restart.
17
+ */
18
+ export class OwnValidatorSlashMonitor {
19
+ /**
20
+ * Tally for the single round being tracked (votes are only ever cast for the current round). `nextVoteIndex` is
21
+ * the cursor: the index of the first vote in the round not yet processed. It is undefined until the startup
22
+ * baseline read completes; if that read fails, the first event falls back to counting from the latest vote only,
23
+ * so votes cast before the node subscribed are never replayed into the cumulative metrics.
24
+ * The tally is per flattened committee position because that is the unit the contract tallies quorum by.
25
+ */
26
+ private state: { round: bigint; nextVoteIndex: bigint | undefined; countByPosition: Map<number, number> } = {
27
+ round: -1n,
28
+ nextVoteIndex: undefined,
29
+ countByPosition: new Map(),
30
+ };
31
+
32
+ /**
33
+ * All vote processing (including the startup baseline) runs through this queue, one job at a time in arrival
34
+ * order. Serialization is what makes the cursor sound: without it, concurrent event handlers could process the
35
+ * same index twice or emit warnings with out-of-order running tallies. Replaced on every start(), since a queue
36
+ * that has been ended cannot run jobs again.
37
+ */
38
+ private queue = new SerialQueue();
39
+
40
+ private stopped = false;
41
+
42
+ constructor(
43
+ private readonly slashingProposer: SlashingProposerContract,
44
+ private readonly settings: { slashingQuorumSize: number },
45
+ private readonly ownValidators: EthAddress[],
46
+ private readonly metrics: SlasherMetrics,
47
+ private readonly log = createLogger('slasher:own-validators'),
48
+ ) {
49
+ this.queue.start();
50
+ }
51
+
52
+ private get enabled(): boolean {
53
+ return this.ownValidators.length > 0;
54
+ }
55
+
56
+ /**
57
+ * Starts tracking at the given round. Reads a baseline of the round's current vote count so votes cast before
58
+ * startup are skipped: replaying them would warn about votes the operator can no longer react to sooner, and
59
+ * would double-count cumulative counters across restarts. Await the returned promise before subscribing to vote
60
+ * events, so a vote cast after the baseline read always sits at an index past the cursor and is caught up by a
61
+ * later drain instead of being skipped as pre-startup. A failed baseline read resolves rather than rejects, and
62
+ * the first drain falls back to processing the latest vote only.
63
+ */
64
+ public start(currentRound: bigint): Promise<void> {
65
+ if (!this.enabled) {
66
+ return Promise.resolve();
67
+ }
68
+ this.stopped = false;
69
+ this.queue = new SerialQueue();
70
+ this.queue.start();
71
+ this.state = { round: currentRound, nextVoteIndex: undefined, countByPosition: new Map() };
72
+ this.metrics.recordQuorumSize(this.settings.slashingQuorumSize);
73
+ this.metrics.recordCurrentRoundVotesMax(0);
74
+ return this.queue
75
+ .put(async () => {
76
+ const { voteCount } = await this.slashingProposer.getRound(currentRound);
77
+ // The round may have rolled while awaiting, in which case the cursor is already 0 for the new round
78
+ if (!this.stopped && this.state.round === currentRound && this.state.nextVoteIndex === undefined) {
79
+ this.state.nextVoteIndex = voteCount;
80
+ }
81
+ })
82
+ .catch(err => this.log.error('Error processing slashing votes', err));
83
+ }
84
+
85
+ /** Stops processing and waits for any in-flight drain, so no warning or metric is emitted after shutdown. */
86
+ public async stop(): Promise<void> {
87
+ this.stopped = true;
88
+ // Jobs already queued are no-ops now that the flag is set, so end() runs through them at no cost while still
89
+ // settling the promises their callers hold, where cancel() would discard them and leave those promises pending.
90
+ await this.queue.end();
91
+ }
92
+
93
+ /**
94
+ * Called by the round monitor clock. Strictly greater so the clock catching up to a round an event already rolled
95
+ * to keeps the tally.
96
+ */
97
+ public handleNewRound(round: bigint): void {
98
+ if (this.enabled && round > this.state.round) {
99
+ this.rollTo(round);
100
+ }
101
+ }
102
+
103
+ /** Called on each VoteCast event. The event is only a trigger: the drain reads all not-yet-seen votes from L1. */
104
+ public handleVoteCast(round: bigint): Promise<void> {
105
+ if (!this.enabled || this.stopped) {
106
+ return Promise.resolve();
107
+ }
108
+ // Errors are logged rather than propagated: callers fire and forget, and a failed read is retried by the drain
109
+ // the next event triggers.
110
+ return this.queue
111
+ .put(() => this.drainVotes(round))
112
+ .catch(err => this.log.error('Error processing slashing votes', err));
113
+ }
114
+
115
+ /** Reports executed slashes against own validators. Called with the Slashed events already fetched by the client. */
116
+ public handleSlashes(round: bigint, slashes: { attester: EthAddress; amount: bigint }[], l1BlockHash: Hex): void {
117
+ if (!this.enabled || this.stopped) {
118
+ return;
119
+ }
120
+ for (const { attester, amount } of slashes.filter(slash => this.isOwnValidator(slash.attester))) {
121
+ this.log.warn(`Own validator ${attester} was slashed for ${amount}`, {
122
+ round,
123
+ validator: attester.toString(),
124
+ amount,
125
+ l1BlockHash,
126
+ });
127
+ this.metrics.recordOwnValidatorSlashed(amount);
128
+ }
129
+ }
130
+
131
+ /** Processes votes [cursor, voteCount) of a round, in order, advancing the cursor after each success. */
132
+ private async drainVotes(round: bigint): Promise<void> {
133
+ if (this.stopped) {
134
+ return; // enqueued before stop() but not yet started: emit nothing and read nothing after shutdown
135
+ }
136
+ if (round < this.state.round) {
137
+ return; // a vote for a round that already closed can no longer reach quorum
138
+ }
139
+ if (round > this.state.round) {
140
+ this.rollTo(round); // the event beat the round-monitor clock to the boundary
141
+ }
142
+
143
+ const { voteCount } = await this.slashingProposer.getRound(round);
144
+ if (this.stopped || round !== this.state.round) {
145
+ return; // stopped, or rolled to a newer round while awaiting
146
+ }
147
+
148
+ // If the startup baseline failed, start from the latest vote only: earlier votes predate the subscription
149
+ const from = this.state.nextVoteIndex ?? maxBigint(voteCount - 1n, 0n);
150
+ for (let index = from; index < voteCount; index++) {
151
+ const vote = await this.slashingProposer.getVoteAt(round, index);
152
+ if (this.stopped || round !== this.state.round) {
153
+ return; // stopped, or rolled mid-drain: either way this vote must no longer be counted
154
+ }
155
+ this.processVote(round, vote);
156
+ // Advanced only after successful processing, so a failed read is retried on the next event
157
+ this.state.nextVoteIndex = index + 1n;
158
+ }
159
+ }
160
+
161
+ private processVote(round: bigint, vote: SlashVoteTarget[]): void {
162
+ const own = vote.filter(target => this.isOwnValidator(target.validator));
163
+ for (const { position } of own) {
164
+ this.state.countByPosition.set(position, (this.state.countByPosition.get(position) ?? 0) + 1);
165
+ }
166
+
167
+ // Warn once per (vote, validator) at the validator's highest position tally: the operator cares about the
168
+ // validator, not which of its committee seats is being voted on. No slash amount (the amount voted is not
169
+ // necessarily the amount slashed) and no proposer (a catch-up drain cannot attribute votes to proposers).
170
+ const quorum = this.settings.slashingQuorumSize;
171
+ for (const { validator } of uniqueBy(own, target => target.validator.toString())) {
172
+ const votes = Math.max(
173
+ ...own.filter(t => t.validator.equals(validator)).map(t => this.state.countByPosition.get(t.position)!),
174
+ );
175
+ this.metrics.recordOwnValidatorTargeted();
176
+ this.log.warn(
177
+ `Own validator ${validator} targeted by slashing vote (${votes} of ${quorum} votes needed to slash)`,
178
+ { round, validator: validator.toString(), votes, quorum },
179
+ );
180
+ }
181
+
182
+ this.metrics.recordCurrentRoundVotesMax(Math.max(0, ...this.state.countByPosition.values()));
183
+ }
184
+
185
+ /** Starts a fresh tally, zeroing the gauge so a quiet round does not keep the previous round's value. */
186
+ private rollTo(round: bigint): void {
187
+ this.state = { round, nextVoteIndex: 0n, countByPosition: new Map() };
188
+ this.metrics.recordCurrentRoundVotesMax(0);
189
+ this.metrics.recordQuorumSize(this.settings.slashingQuorumSize); // keeps the gauge fresh across export cycles
190
+ }
191
+
192
+ private isOwnValidator(address: EthAddress): boolean {
193
+ return this.ownValidators.some(validator => validator.equals(address));
194
+ }
195
+ }