@aztec/slasher 0.0.1-commit.b2a5d0dd1 → 0.0.1-commit.b3d3157a

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 (61) hide show
  1. package/README.md +54 -33
  2. package/dest/config.d.ts +1 -1
  3. package/dest/config.d.ts.map +1 -1
  4. package/dest/config.js +27 -15
  5. package/dest/factory/create_facade.d.ts +2 -2
  6. package/dest/factory/create_facade.d.ts.map +1 -1
  7. package/dest/generated/slasher-defaults.d.ts +5 -3
  8. package/dest/generated/slasher-defaults.d.ts.map +1 -1
  9. package/dest/generated/slasher-defaults.js +4 -2
  10. package/dest/index.d.ts +5 -2
  11. package/dest/index.d.ts.map +1 -1
  12. package/dest/index.js +4 -1
  13. package/dest/metrics.d.ts +7 -0
  14. package/dest/metrics.d.ts.map +1 -0
  15. package/dest/metrics.js +11 -0
  16. package/dest/slash_offenses_collector.d.ts +6 -2
  17. package/dest/slash_offenses_collector.d.ts.map +1 -1
  18. package/dest/slash_offenses_collector.js +46 -17
  19. package/dest/slasher_client.d.ts +4 -2
  20. package/dest/slasher_client.d.ts.map +1 -1
  21. package/dest/slasher_client.js +26 -7
  22. package/dest/stores/offenses_store.d.ts +9 -3
  23. package/dest/stores/offenses_store.d.ts.map +1 -1
  24. package/dest/stores/offenses_store.js +60 -18
  25. package/dest/watcher.d.ts +8 -1
  26. package/dest/watcher.d.ts.map +1 -1
  27. package/dest/watcher.js +1 -0
  28. package/dest/watchers/attestations_block_watcher.d.ts +26 -13
  29. package/dest/watchers/attestations_block_watcher.d.ts.map +1 -1
  30. package/dest/watchers/attestations_block_watcher.js +76 -61
  31. package/dest/watchers/attested_invalid_proposal_watcher.d.ts +42 -0
  32. package/dest/watchers/attested_invalid_proposal_watcher.d.ts.map +1 -0
  33. package/dest/watchers/attested_invalid_proposal_watcher.js +117 -0
  34. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts +38 -0
  35. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts.map +1 -0
  36. package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.js +138 -0
  37. package/dest/watchers/checkpoint_equivocation_watcher.d.ts +30 -0
  38. package/dest/watchers/checkpoint_equivocation_watcher.d.ts.map +1 -0
  39. package/dest/watchers/checkpoint_equivocation_watcher.js +69 -0
  40. package/dest/watchers/data_withholding_watcher.d.ts +63 -0
  41. package/dest/watchers/data_withholding_watcher.d.ts.map +1 -0
  42. package/dest/watchers/data_withholding_watcher.js +193 -0
  43. package/package.json +10 -10
  44. package/src/config.ts +32 -15
  45. package/src/factory/create_facade.ts +1 -1
  46. package/src/generated/slasher-defaults.ts +4 -2
  47. package/src/index.ts +4 -1
  48. package/src/metrics.ts +19 -0
  49. package/src/slash_offenses_collector.ts +60 -18
  50. package/src/slasher_client.ts +35 -6
  51. package/src/stores/offenses_store.ts +71 -20
  52. package/src/watcher.ts +8 -0
  53. package/src/watchers/attestations_block_watcher.ts +88 -82
  54. package/src/watchers/attested_invalid_proposal_watcher.ts +168 -0
  55. package/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts +192 -0
  56. package/src/watchers/checkpoint_equivocation_watcher.ts +96 -0
  57. package/src/watchers/data_withholding_watcher.ts +225 -0
  58. package/dest/watchers/epoch_prune_watcher.d.ts +0 -39
  59. package/dest/watchers/epoch_prune_watcher.d.ts.map +0 -1
  60. package/dest/watchers/epoch_prune_watcher.js +0 -179
  61. package/src/watchers/epoch_prune_watcher.ts +0 -256
@@ -0,0 +1,193 @@
1
+ import { CheckpointProposalHash, SlotNumber } from '@aztec/foundation/branded-types';
2
+ import { compactArray, merge, pick } from '@aztec/foundation/collection';
3
+ import { createLogger } from '@aztec/foundation/log';
4
+ import { RunningPromise } from '@aztec/foundation/promise';
5
+ import { getAttestationInfoFromPublishedCheckpoint } from '@aztec/stdlib/block';
6
+ import { ConsensusPayload } from '@aztec/stdlib/p2p';
7
+ import { OffenseType, getOffenseTypeName } from '@aztec/stdlib/slashing';
8
+ import EventEmitter from 'node:events';
9
+ import { WANT_TO_SLASH_EVENT } from '../watcher.js';
10
+ const DataWithholdingWatcherConfigKeys = [
11
+ 'slashDataWithholdingPenalty',
12
+ 'slashDataWithholdingToleranceSlots'
13
+ ];
14
+ /**
15
+ * Detects data-withholding offenses by probing the local mempool for the txs in published
16
+ * checkpoints once they are old enough that an honest node should have collected them.
17
+ *
18
+ * Per AZIP-7: once `slashDataWithholdingToleranceSlots` full slots have elapsed after the
19
+ * checkpoint's slot — i.e. at `slotStart(checkpoint.slot + slashDataWithholdingToleranceSlots
20
+ * + 1)` — if any tx from the checkpoint's blocks is still missing locally, the checkpoint's
21
+ * attesters are considered at fault for not making the data available, and we emit a slash
22
+ * for them.
23
+ *
24
+ * The watcher ticks at quarter-eth-slot cadence (matching the Sentinel template). On boot it
25
+ * floors processing at the current slot — restart-time gaps are accepted and not back-filled,
26
+ * matching the Sentinel approach.
27
+ */ export class DataWithholdingWatcher extends EventEmitter {
28
+ epochCache;
29
+ l2BlockSource;
30
+ txProvider;
31
+ p2p;
32
+ reexecutionTracker;
33
+ signatureContext;
34
+ log;
35
+ runningPromise;
36
+ initialSlot;
37
+ lastCheckedSlot;
38
+ config;
39
+ constructor(epochCache, l2BlockSource, txProvider, p2p, reexecutionTracker, signatureContext, config, log = createLogger('data-withholding-watcher')){
40
+ super(), this.epochCache = epochCache, this.l2BlockSource = l2BlockSource, this.txProvider = txProvider, this.p2p = p2p, this.reexecutionTracker = reexecutionTracker, this.signatureContext = signatureContext, this.log = log;
41
+ this.config = pick(config, ...DataWithholdingWatcherConfigKeys);
42
+ const interval = epochCache.getL1Constants().ethereumSlotDuration * 1000 / 4;
43
+ this.runningPromise = new RunningPromise(this.work.bind(this), log, interval);
44
+ this.log.verbose(`DataWithholdingWatcher initialized`, this.config);
45
+ }
46
+ async start() {
47
+ // Floor processing at the archiver's synced slot rather than the wallclock — restart-time
48
+ // gaps before the archiver catches up are accepted and not back-filled. Falls back to the
49
+ // wallclock if the archiver isn't ready yet (cold start).
50
+ const syncedSlot = await this.l2BlockSource.getSyncedL2SlotNumber();
51
+ this.initialSlot = syncedSlot ?? this.epochCache.getSlotNow();
52
+ this.log.info(`Starting data-withholding watcher with initial slot ${this.initialSlot}`);
53
+ this.runningPromise.start();
54
+ }
55
+ stop() {
56
+ return this.runningPromise.stop();
57
+ }
58
+ updateConfig(config) {
59
+ this.config = merge(this.config, pick(config, ...DataWithholdingWatcherConfigKeys));
60
+ this.log.verbose('DataWithholdingWatcher config updated', this.config);
61
+ }
62
+ /**
63
+ * Runs every tick. Walks newly-eligible slots and probes their checkpoints for data
64
+ * availability; emits a DATA_WITHHOLDING slash for any checkpoint whose txs are missing.
65
+ */ async work() {
66
+ if (this.initialSlot === undefined) {
67
+ return;
68
+ }
69
+ // tolerance is the number of full slots that must elapse after the checkpoint's slot
70
+ // before we declare its data missing. For checkpoint slot S, we therefore process S
71
+ // only once we are in slot `S + tolerance + 1` or later. Drive this off the archiver's
72
+ // synced slot rather than the wallclock so we don't make claims about slots we haven't
73
+ // fully ingested yet (archiver may lag behind L1).
74
+ const tolerance = this.config.slashDataWithholdingToleranceSlots;
75
+ const currentSlot = await this.l2BlockSource.getSyncedL2SlotNumber() ?? this.epochCache.getSlotNow();
76
+ if (currentSlot <= tolerance) {
77
+ return;
78
+ }
79
+ const targetSlot = SlotNumber(currentSlot - tolerance - 1);
80
+ if (targetSlot <= this.initialSlot) {
81
+ return;
82
+ }
83
+ const startSlot = this.lastCheckedSlot === undefined ? this.initialSlot : this.lastCheckedSlot;
84
+ for(let slot = SlotNumber(startSlot + 1); slot <= targetSlot; slot = SlotNumber(slot + 1)){
85
+ try {
86
+ await this.processSlot(slot);
87
+ } catch (err) {
88
+ this.log.error(`Error processing slot ${slot} for data-withholding check`, err, {
89
+ slot
90
+ });
91
+ }
92
+ this.lastCheckedSlot = slot;
93
+ }
94
+ }
95
+ /** Probes the checkpoint at the given slot, if any, and emits a slash on missing txs. */ async processSlot(slot) {
96
+ const published = await this.l2BlockSource.getCheckpoint({
97
+ slot
98
+ });
99
+ if (!published) {
100
+ this.log.trace(`No published checkpoint at slot ${slot}`, {
101
+ slot
102
+ });
103
+ return;
104
+ }
105
+ const checkpointNumber = published.checkpoint.number;
106
+ // Per-block tx-collection records (true | false | undefined) for every block in this
107
+ // published checkpoint. Captured by the validator's proposal handler at the moment of
108
+ // tx collection (i.e. by the *re-execution* deadline). Used as a positive short-circuit
109
+ // only: a `true` for every block means we know the data was available locally, so this
110
+ // checkpoint cannot be a data-withholding offense. A `false` does *not* trigger a slash
111
+ // on its own — the re-execution deadline is much earlier than the data-withholding
112
+ // tolerance window, so missing txs at that earlier deadline may still arrive in time.
113
+ // Anything other than all-true falls through to the mempool probe, which respects the
114
+ // tolerance window.
115
+ const collectionRecords = published.checkpoint.blocks.map((block, idx)=>this.reexecutionTracker.getTxsCollectedRecord(block.header.getSlot(), idx));
116
+ if (collectionRecords.every((r)=>r === true)) {
117
+ this.log.trace(`All blocks for checkpoint at slot ${slot} were collected locally; skipping`, {
118
+ slot,
119
+ checkpointNumber
120
+ });
121
+ return;
122
+ }
123
+ const txHashes = published.checkpoint.blocks.flatMap((block)=>block.body.txEffects.map((txEffect)=>txEffect.txHash));
124
+ if (txHashes.length === 0) {
125
+ this.log.trace(`Checkpoint at slot ${slot} has no txs`, {
126
+ slot
127
+ });
128
+ return;
129
+ }
130
+ const availability = await this.txProvider.hasTxs(txHashes);
131
+ const missingTxs = txHashes.filter((_, i)=>!availability[i]);
132
+ if (missingTxs.length === 0) {
133
+ this.log.trace(`All ${txHashes.length} txs available for checkpoint at slot ${slot}`, {
134
+ slot
135
+ });
136
+ return;
137
+ }
138
+ const attesters = await this.extractAttesters(published);
139
+ if (attesters.length === 0) {
140
+ this.log.warn(`Detected data withholding at slot ${slot} but no recoverable attesters`, {
141
+ slot,
142
+ checkpointNumber,
143
+ missingTxs: missingTxs.map((h)=>h.toString()),
144
+ records: collectionRecords
145
+ });
146
+ return;
147
+ }
148
+ this.log.info(`Detected data withholding offense at slot ${slot}`, {
149
+ slot,
150
+ checkpointNumber,
151
+ amount: this.config.slashDataWithholdingPenalty,
152
+ offenseType: getOffenseTypeName(OffenseType.DATA_WITHHOLDING),
153
+ missingTxs: missingTxs.map((h)=>h.toString()),
154
+ records: collectionRecords,
155
+ attesters: attesters.map((a)=>a.toString())
156
+ });
157
+ const args = attesters.map((validator)=>({
158
+ validator,
159
+ amount: this.config.slashDataWithholdingPenalty,
160
+ offenseType: OffenseType.DATA_WITHHOLDING,
161
+ epochOrSlot: BigInt(slot)
162
+ }));
163
+ this.emit(WANT_TO_SLASH_EVENT, args);
164
+ }
165
+ /**
166
+ * Returns the union of:
167
+ * 1. attesters whose signatures landed in the published checkpoint on L1, and
168
+ * 2. attesters we observed signing the same proposal on p2p (the proposer publishes as
169
+ * soon as it has hit committee quorum, so honest peer attestations that arrive after
170
+ * that point are dropped — but they still vouched for the data and
171
+ * should be slashed for withholding it).
172
+ *
173
+ *
174
+ * Exposed as protected so tests can substitute a deterministic recovery without having
175
+ * to construct real secp256k1 signatures.
176
+ */ async extractAttesters(published) {
177
+ const fromL1 = getAttestationInfoFromPublishedCheckpoint(published, this.signatureContext).filter((info)=>info.status === 'recovered-from-signature').map((info)=>info.address);
178
+ const slot = published.checkpoint.header.slotNumber;
179
+ const proposalPayloadHash = CheckpointProposalHash.fromBuffer(ConsensusPayload.fromCheckpoint(published.checkpoint, this.signatureContext).getPayloadHash());
180
+ const fromP2p = await this.p2p.getCheckpointAttestationsForSlot(slot, proposalPayloadHash).then((attestations)=>attestations.map((a)=>a.getSender()));
181
+ // Dedupe
182
+ const all = new Map();
183
+ for (const addr of compactArray([
184
+ ...fromL1,
185
+ ...fromP2p
186
+ ])){
187
+ all.set(addr.toString(), addr);
188
+ }
189
+ return [
190
+ ...all.values()
191
+ ];
192
+ }
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/slasher",
3
- "version": "0.0.1-commit.b2a5d0dd1",
3
+ "version": "0.0.1-commit.b3d3157a",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dest/index.js",
@@ -56,20 +56,20 @@
56
56
  ]
57
57
  },
58
58
  "dependencies": {
59
- "@aztec/epoch-cache": "0.0.1-commit.b2a5d0dd1",
60
- "@aztec/ethereum": "0.0.1-commit.b2a5d0dd1",
61
- "@aztec/foundation": "0.0.1-commit.b2a5d0dd1",
62
- "@aztec/kv-store": "0.0.1-commit.b2a5d0dd1",
63
- "@aztec/l1-artifacts": "0.0.1-commit.b2a5d0dd1",
64
- "@aztec/stdlib": "0.0.1-commit.b2a5d0dd1",
65
- "@aztec/telemetry-client": "0.0.1-commit.b2a5d0dd1",
59
+ "@aztec/epoch-cache": "0.0.1-commit.b3d3157a",
60
+ "@aztec/ethereum": "0.0.1-commit.b3d3157a",
61
+ "@aztec/foundation": "0.0.1-commit.b3d3157a",
62
+ "@aztec/kv-store": "0.0.1-commit.b3d3157a",
63
+ "@aztec/l1-artifacts": "0.0.1-commit.b3d3157a",
64
+ "@aztec/stdlib": "0.0.1-commit.b3d3157a",
65
+ "@aztec/telemetry-client": "0.0.1-commit.b3d3157a",
66
66
  "source-map-support": "^0.5.21",
67
67
  "tslib": "^2.4.0",
68
68
  "viem": "npm:@aztec/viem@2.38.2",
69
- "zod": "^3.23.8"
69
+ "zod": "^4"
70
70
  },
71
71
  "devDependencies": {
72
- "@aztec/aztec.js": "0.0.1-commit.b2a5d0dd1",
72
+ "@aztec/aztec.js": "0.0.1-commit.b3d3157a",
73
73
  "@jest/globals": "^30.0.0",
74
74
  "@types/jest": "^30.0.0",
75
75
  "@types/node": "^22.15.17",
package/src/config.ts CHANGED
@@ -16,16 +16,22 @@ export const DefaultSlasherConfig: SlasherConfig = {
16
16
  slashOverridePayload: undefined,
17
17
  slashValidatorsAlways: [], // Empty by default
18
18
  slashValidatorsNever: [], // Empty by default
19
- slashPrunePenalty: BigInt(slasherDefaultEnv.SLASH_PRUNE_PENALTY),
20
19
  slashDataWithholdingPenalty: BigInt(slasherDefaultEnv.SLASH_DATA_WITHHOLDING_PENALTY),
20
+ slashDataWithholdingToleranceSlots: slasherDefaultEnv.SLASH_DATA_WITHHOLDING_TOLERANCE_SLOTS,
21
21
  slashInactivityTargetPercentage: slasherDefaultEnv.SLASH_INACTIVITY_TARGET_PERCENTAGE,
22
22
  slashInactivityConsecutiveEpochThreshold: slasherDefaultEnv.SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD,
23
23
  slashBroadcastedInvalidBlockPenalty: BigInt(slasherDefaultEnv.SLASH_INVALID_BLOCK_PENALTY),
24
+ slashBroadcastedInvalidCheckpointProposalPenalty: BigInt(slasherDefaultEnv.SLASH_INVALID_CHECKPOINT_PROPOSAL_PENALTY),
24
25
  slashDuplicateProposalPenalty: BigInt(slasherDefaultEnv.SLASH_DUPLICATE_PROPOSAL_PENALTY),
25
26
  slashDuplicateAttestationPenalty: BigInt(slasherDefaultEnv.SLASH_DUPLICATE_ATTESTATION_PENALTY),
26
27
  slashInactivityPenalty: BigInt(slasherDefaultEnv.SLASH_INACTIVITY_PENALTY),
27
28
  slashProposeInvalidAttestationsPenalty: BigInt(slasherDefaultEnv.SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY),
28
- slashAttestDescendantOfInvalidPenalty: BigInt(slasherDefaultEnv.SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY),
29
+ slashProposeDescendantOfCheckpointWithInvalidAttestationsPenalty: BigInt(
30
+ slasherDefaultEnv.SLASH_PROPOSE_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS_PENALTY,
31
+ ),
32
+ slashAttestInvalidCheckpointProposalPenalty: BigInt(
33
+ slasherDefaultEnv.SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY,
34
+ ),
29
35
  slashUnknownPenalty: BigInt(slasherDefaultEnv.SLASH_UNKNOWN_PENALTY),
30
36
  slashOffenseExpirationRounds: slasherDefaultEnv.SLASH_OFFENSE_EXPIRATION_ROUNDS,
31
37
  slashMaxPayloadSize: slasherDefaultEnv.SLASH_MAX_PAYLOAD_SIZE,
@@ -63,21 +69,27 @@ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
63
69
  .map(addr => EthAddress.fromString(addr)),
64
70
  defaultValue: DefaultSlasherConfig.slashValidatorsNever,
65
71
  },
66
- slashPrunePenalty: {
67
- env: 'SLASH_PRUNE_PENALTY',
68
- description: 'Penalty amount for slashing validators of a valid pruned epoch (set to 0 to disable).',
69
- ...bigintConfigHelper(DefaultSlasherConfig.slashPrunePenalty),
70
- },
71
72
  slashDataWithholdingPenalty: {
72
73
  env: 'SLASH_DATA_WITHHOLDING_PENALTY',
73
- description: 'Penalty amount for slashing validators for data withholding (set to 0 to disable).',
74
+ description: 'Penalty for data withholding (0 records offenses without slash votes).',
74
75
  ...bigintConfigHelper(DefaultSlasherConfig.slashDataWithholdingPenalty),
75
76
  },
77
+ slashDataWithholdingToleranceSlots: {
78
+ env: 'SLASH_DATA_WITHHOLDING_TOLERANCE_SLOTS',
79
+ description:
80
+ 'Number of full L2 slots that must elapse after a checkpoint slot before declaring its txs missing and slashing its attesters for data withholding.',
81
+ ...numberConfigHelper(DefaultSlasherConfig.slashDataWithholdingToleranceSlots),
82
+ },
76
83
  slashBroadcastedInvalidBlockPenalty: {
77
84
  env: 'SLASH_INVALID_BLOCK_PENALTY',
78
85
  description: 'Penalty amount for slashing a validator for an invalid block proposed via p2p.',
79
86
  ...bigintConfigHelper(DefaultSlasherConfig.slashBroadcastedInvalidBlockPenalty),
80
87
  },
88
+ slashBroadcastedInvalidCheckpointProposalPenalty: {
89
+ env: 'SLASH_INVALID_CHECKPOINT_PROPOSAL_PENALTY',
90
+ description: 'Penalty amount for slashing a validator for an invalid checkpoint proposal proposed via p2p.',
91
+ ...bigintConfigHelper(DefaultSlasherConfig.slashBroadcastedInvalidCheckpointProposalPenalty),
92
+ },
81
93
  slashDuplicateProposalPenalty: {
82
94
  env: 'SLASH_DUPLICATE_PROPOSAL_PENALTY',
83
95
  description: 'Penalty amount for slashing a validator for sending duplicate proposals.',
@@ -113,23 +125,28 @@ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
113
125
  },
114
126
  slashInactivityPenalty: {
115
127
  env: 'SLASH_INACTIVITY_PENALTY',
116
- description: 'Penalty amount for slashing an inactive validator (set to 0 to disable).',
128
+ description: 'Penalty for an inactive validator (0 records offenses without slash votes).',
117
129
  ...bigintConfigHelper(DefaultSlasherConfig.slashInactivityPenalty),
118
130
  },
119
131
  slashProposeInvalidAttestationsPenalty: {
120
132
  env: 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY',
121
- description: 'Penalty amount for slashing a proposer that proposed invalid attestations (set to 0 to disable).',
133
+ description: 'Penalty for proposing invalid attestations (0 records offenses without slash votes).',
122
134
  ...bigintConfigHelper(DefaultSlasherConfig.slashProposeInvalidAttestationsPenalty),
123
135
  },
124
- slashAttestDescendantOfInvalidPenalty: {
125
- env: 'SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY',
136
+ slashProposeDescendantOfCheckpointWithInvalidAttestationsPenalty: {
137
+ env: 'SLASH_PROPOSE_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS_PENALTY',
126
138
  description:
127
- 'Penalty amount for slashing a validator that attested to a descendant of an invalid block (set to 0 to disable).',
128
- ...bigintConfigHelper(DefaultSlasherConfig.slashAttestDescendantOfInvalidPenalty),
139
+ 'Penalty for publishing a checkpoint building on an invalid checkpoint (0 records offenses without slash votes).',
140
+ ...bigintConfigHelper(DefaultSlasherConfig.slashProposeDescendantOfCheckpointWithInvalidAttestationsPenalty),
141
+ },
142
+ slashAttestInvalidCheckpointProposalPenalty: {
143
+ env: 'SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY',
144
+ description: 'Penalty for attesting to an invalid checkpoint proposal (0 records offenses without slash votes).',
145
+ ...bigintConfigHelper(DefaultSlasherConfig.slashAttestInvalidCheckpointProposalPenalty),
129
146
  },
130
147
  slashUnknownPenalty: {
131
148
  env: 'SLASH_UNKNOWN_PENALTY',
132
- description: 'Penalty amount for slashing a validator for an unknown offense (set to 0 to disable).',
149
+ description: 'Penalty for an unknown offense (0 records offenses without slash votes).',
133
150
  ...bigintConfigHelper(DefaultSlasherConfig.slashUnknownPenalty),
134
151
  },
135
152
  slashOffenseExpirationRounds: {
@@ -20,7 +20,7 @@ import type { Watcher } from '../watcher.js';
20
20
  /** Creates a slasher client facade that updates itself whenever the rollup slasher changes */
21
21
  export async function createSlasherFacade(
22
22
  config: SlasherConfig & DataStoreConfig & { ethereumSlotDuration: number },
23
- l1Contracts: Pick<L1ReaderConfig['l1Contracts'], 'rollupAddress' | 'registryAddress'>,
23
+ l1Contracts: Pick<L1ReaderConfig, 'rollupAddress' | 'registryAddress'>,
24
24
  l1Client: ViemClient,
25
25
  watchers: Watcher[],
26
26
  dateProvider: DateProvider,
@@ -6,16 +6,18 @@ export const slasherDefaultEnv = {
6
6
  SLASH_OFFENSE_EXPIRATION_ROUNDS: 4,
7
7
  SLASH_MAX_PAYLOAD_SIZE: 80,
8
8
  SLASH_EXECUTE_ROUNDS_LOOK_BACK: 4,
9
- SLASH_PRUNE_PENALTY: 10000000000000000000,
10
9
  SLASH_DATA_WITHHOLDING_PENALTY: 10000000000000000000,
10
+ SLASH_DATA_WITHHOLDING_TOLERANCE_SLOTS: 3,
11
11
  SLASH_INACTIVITY_TARGET_PERCENTAGE: 0.9,
12
12
  SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD: 1,
13
13
  SLASH_INACTIVITY_PENALTY: 10000000000000000000,
14
14
  SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY: 10000000000000000000,
15
- SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY: 10000000000000000000,
15
+ SLASH_PROPOSE_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS_PENALTY: 10000000000000000000,
16
+ SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY: 10000000000000000000,
16
17
  SLASH_DUPLICATE_PROPOSAL_PENALTY: 0,
17
18
  SLASH_DUPLICATE_ATTESTATION_PENALTY: 0,
18
19
  SLASH_UNKNOWN_PENALTY: 10000000000000000000,
19
20
  SLASH_INVALID_BLOCK_PENALTY: 10000000000000000000,
21
+ SLASH_INVALID_CHECKPOINT_PROPOSAL_PENALTY: 0,
20
22
  SLASH_GRACE_PERIOD_L2_SLOTS: 0,
21
23
  } as const;
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
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 './watchers/attested_invalid_proposal_watcher.js';
5
+ export * from './watchers/broadcasted_invalid_checkpoint_proposal_watcher.js';
6
+ export * from './watchers/checkpoint_equivocation_watcher.js';
4
7
  export * from './slasher_client.js';
5
8
  export * from './slash_offenses_collector.js';
6
9
  export * from './slasher_client_interface.js';
package/src/metrics.ts ADDED
@@ -0,0 +1,19 @@
1
+ import {
2
+ Metrics,
3
+ type TelemetryClient,
4
+ type UpDownCounter,
5
+ createUpDownCounterWithDefault,
6
+ } from '@aztec/telemetry-client';
7
+
8
+ export class SlasherMetrics {
9
+ private readonly roundExecuted: UpDownCounter;
10
+
11
+ constructor(client: TelemetryClient, name = 'Slasher') {
12
+ const meter = client.getMeter(name);
13
+ this.roundExecuted = createUpDownCounterWithDefault(meter, Metrics.SLASHER_ROUND_EXECUTED_COUNT);
14
+ }
15
+
16
+ public recordRoundExecuted(): void {
17
+ this.roundExecuted.add(1);
18
+ }
19
+ }
@@ -1,12 +1,19 @@
1
1
  import type { SlotNumber } from '@aztec/foundation/branded-types';
2
2
  import { createLogger } from '@aztec/foundation/log';
3
+ import { SerialQueue } from '@aztec/foundation/queue';
3
4
  import type { Prettify } from '@aztec/foundation/types';
4
5
  import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
5
6
  import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
6
- import { type Offense, getSlotForOffense } from '@aztec/stdlib/slashing';
7
+ import { type Offense, getOffenseTypeName, getSlotForOffense } from '@aztec/stdlib/slashing';
7
8
 
8
9
  import type { SlasherOffensesStore } from './stores/offenses_store.js';
9
- import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher } from './watcher.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';
10
17
 
11
18
  export type SlashOffensesCollectorConfig = Prettify<Pick<SlasherConfig, 'slashGracePeriodL2Slots'>>;
12
19
  export type SlashOffensesCollectorSettings = Prettify<
@@ -24,6 +31,7 @@ export type SlashOffensesCollectorSettings = Prettify<
24
31
  */
25
32
  export class SlashOffensesCollector {
26
33
  private readonly unwatchCallbacks: (() => void)[] = [];
34
+ private readonly storeMutationQueue = new SerialQueue();
27
35
 
28
36
  constructor(
29
37
  private readonly config: SlashOffensesCollectorConfig,
@@ -35,28 +43,35 @@ export class SlashOffensesCollector {
35
43
 
36
44
  public start() {
37
45
  this.log.debug('Starting SlashOffensesCollector...');
46
+ this.storeMutationQueue.start();
38
47
 
39
- // Subscribe to watchers WANT_TO_SLASH_EVENT
48
+ // Subscribe to watcher slashing events.
40
49
  for (const watcher of this.watchers) {
41
50
  const wantToSlashCallback = (args: WantToSlashArgs[]) =>
42
- void this.handleWantToSlash(args).catch(err => this.log.error('Error handling wantToSlash', err));
51
+ this.enqueueStoreMutation('wantToSlash', () => this.handleWantToSlash(args));
43
52
  watcher.on(WANT_TO_SLASH_EVENT, wantToSlashCallback);
44
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));
45
59
  }
46
60
 
47
61
  this.log.info('Started SlashOffensesCollector');
48
62
  return Promise.resolve();
49
63
  }
50
64
 
51
- public stop() {
65
+ public async stop() {
52
66
  this.log.debug('Stopping SlashOffensesCollector...');
53
67
 
54
68
  for (const unwatchCallback of this.unwatchCallbacks) {
55
69
  unwatchCallback();
56
70
  }
57
71
 
72
+ await this.storeMutationQueue.end();
73
+
58
74
  this.log.info('SlashOffensesCollector stopped');
59
- return Promise.resolve();
60
75
  }
61
76
 
62
77
  /**
@@ -74,24 +89,39 @@ export class SlashOffensesCollector {
74
89
  };
75
90
 
76
91
  if (this.shouldSkipOffense(offense)) {
77
- this.log.verbose('Skipping offense during grace period', offense);
78
- continue;
79
- }
80
-
81
- if (await this.offensesStore.hasOffense(offense)) {
82
- this.log.debug('Skipping repeated offense', offense);
92
+ this.log.verbose('Skipping offense during grace period', this.getOffenseLogData(offense));
83
93
  continue;
84
94
  }
85
95
 
86
- if (this.settings.slashingAmounts) {
87
- const minSlash = this.settings.slashingAmounts[0];
88
- if (arg.amount < minSlash) {
89
- this.log.warn(`Offense amount ${arg.amount} is below minimum slashing amount ${minSlash}`);
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
+ }
90
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));
91
111
  }
112
+ }
113
+ }
92
114
 
93
- this.log.info(`Adding pending offense for validator ${arg.validator}`, offense);
94
- await this.offensesStore.addOffense(offense);
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
+ }
95
125
  }
96
126
  }
97
127
 
@@ -111,4 +141,16 @@ export class SlashOffensesCollector {
111
141
  const offenseSlot = getSlotForOffense(offense, this.settings);
112
142
  return offenseSlot < this.settings.rollupRegisteredAtL2Slot + this.config.slashGracePeriodL2Slots;
113
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
+ }
114
156
  }
@@ -14,11 +14,14 @@ import {
14
14
  type ProposerSlashAction,
15
15
  type ProposerSlashActionProvider,
16
16
  getEpochsForRound,
17
+ getOffenseTypeName,
17
18
  getSlashConsensusVotesFromOffenses,
18
19
  } from '@aztec/stdlib/slashing';
20
+ import { getTelemetryClient } from '@aztec/telemetry-client';
19
21
 
20
22
  import type { Hex } from 'viem';
21
23
 
24
+ import { SlasherMetrics } from './metrics.js';
22
25
  import {
23
26
  SlashOffensesCollector,
24
27
  type SlashOffensesCollectorConfig,
@@ -50,6 +53,14 @@ export type SlasherClientConfig = SlashOffensesCollectorConfig &
50
53
  'slashValidatorsAlways' | 'slashValidatorsNever' | 'slashExecuteRoundsLookBack' | 'slashMaxPayloadSize'
51
54
  >;
52
55
 
56
+ type AlwaysSlashOffense = {
57
+ validator: EthAddress;
58
+ amount: bigint;
59
+ offenseType: OffenseType.UNKNOWN;
60
+ };
61
+
62
+ type SlashVoteOffense = Offense | AlwaysSlashOffense;
63
+
53
64
  /**
54
65
  * The Slasher client is responsible for managing slashable offenses using
55
66
  * the consensus-based slashing model where proposers vote on individual validator offenses.
@@ -92,6 +103,7 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
92
103
  private dateProvider: DateProvider,
93
104
  private offensesStore: SlasherOffensesStore,
94
105
  private log = createLogger('slasher:consensus'),
106
+ private readonly metrics = new SlasherMetrics(getTelemetryClient()),
95
107
  ) {
96
108
  this.roundMonitor = new SlashRoundMonitor(settings, dateProvider);
97
109
  this.offensesCollector = new SlashOffensesCollector(config, settings, watchers, offensesStore);
@@ -152,6 +164,7 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
152
164
 
153
165
  /** Called when we see a RoundExecuted event on the SlashingProposer (just for logging). */
154
166
  protected async handleRoundExecuted(round: bigint, slashCount: bigint, l1BlockHash: Hex) {
167
+ this.metrics.recordRoundExecuted();
155
168
  const slashes = await this.rollup.getSlashEvents(l1BlockHash);
156
169
  this.log.info(`Slashing round ${round} has been executed with ${slashCount} slashes`, { slashes });
157
170
  }
@@ -309,7 +322,7 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
309
322
  // Compute offenses to slash, by loading the offenses for this round, adding synthetic offenses
310
323
  // for validators that should always be slashed, and removing the ones that should never be slashed.
311
324
  const offensesForRound = await this.gatherOffensesForRound(currentRound);
312
- const offensesFromAlwaysSlash = (this.config.slashValidatorsAlways ?? []).map(validator => ({
325
+ const offensesFromAlwaysSlash: AlwaysSlashOffense[] = (this.config.slashValidatorsAlways ?? []).map(validator => ({
313
326
  validator,
314
327
  amount: this.settings.slashingAmounts[2],
315
328
  offenseType: OffenseType.UNKNOWN,
@@ -323,7 +336,7 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
323
336
  slotNumber,
324
337
  currentRound,
325
338
  slashedRound,
326
- offensesToForgive,
339
+ offensesFromAlwaysSlash: offensesFromAlwaysSlash.map(getOffenseLogData),
327
340
  slashValidatorsAlways: this.config.slashValidatorsAlways,
328
341
  });
329
342
  }
@@ -333,7 +346,7 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
333
346
  slotNumber,
334
347
  currentRound,
335
348
  slashedRound,
336
- offensesToForgive,
349
+ offensesToForgive: offensesToForgive.map(getOffenseLogData),
337
350
  slashValidatorsNever: this.config.slashValidatorsNever,
338
351
  });
339
352
  }
@@ -343,11 +356,11 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
343
356
  return undefined;
344
357
  }
345
358
 
346
- this.log.info(`Voting to slash ${offensesToSlash.length} offenses`, {
359
+ this.log.debug(`Computing slash votes for ${offensesToSlash.length} offenses`, {
347
360
  slotNumber,
348
361
  currentRound,
349
362
  slashedRound,
350
- offensesToSlash,
363
+ offensesToSlash: offensesToSlash.map(getOffenseLogData),
351
364
  });
352
365
 
353
366
  const committees = await this.collectCommitteesActiveDuringRound(slashedRound);
@@ -365,12 +378,20 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
365
378
  slotNumber,
366
379
  currentRound,
367
380
  slashedRound,
368
- offensesToSlash,
381
+ offensesToSlash: offensesToSlash.map(getOffenseLogData),
369
382
  committees,
370
383
  });
371
384
  return undefined;
372
385
  }
373
386
 
387
+ this.log.info(`Voting to slash ${offensesToSlash.length} offenses`, {
388
+ slotNumber,
389
+ slashedRound,
390
+ currentRound,
391
+ votes,
392
+ offensesToSlash: offensesToSlash.map(getOffenseLogData),
393
+ });
394
+
374
395
  this.log.debug(`Computed votes for slashing ${offensesToSlash.length} offenses`, {
375
396
  slashedRound,
376
397
  currentRound,
@@ -429,3 +450,11 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
429
450
  return round - BigInt(this.settings.slashingOffsetInRounds);
430
451
  }
431
452
  }
453
+
454
+ function getOffenseLogData(offense: SlashVoteOffense) {
455
+ return {
456
+ ...offense,
457
+ validator: offense.validator.toString(),
458
+ offenseType: getOffenseTypeName(offense.offenseType),
459
+ };
460
+ }