@aztec/validator-client 0.0.1-commit.f504929 → 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 (44) hide show
  1. package/README.md +53 -11
  2. package/dest/checkpoint_builder.d.ts +25 -7
  3. package/dest/checkpoint_builder.d.ts.map +1 -1
  4. package/dest/checkpoint_builder.js +109 -30
  5. package/dest/config.d.ts +9 -3
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +41 -7
  8. package/dest/duties/validation_service.d.ts +12 -13
  9. package/dest/duties/validation_service.d.ts.map +1 -1
  10. package/dest/duties/validation_service.js +33 -45
  11. package/dest/factory.d.ts +11 -5
  12. package/dest/factory.d.ts.map +1 -1
  13. package/dest/factory.js +8 -8
  14. package/dest/index.d.ts +2 -2
  15. package/dest/index.d.ts.map +1 -1
  16. package/dest/index.js +1 -1
  17. package/dest/key_store/ha_key_store.js +1 -1
  18. package/dest/key_store/web3signer_key_store.d.ts +10 -2
  19. package/dest/key_store/web3signer_key_store.d.ts.map +1 -1
  20. package/dest/key_store/web3signer_key_store.js +32 -41
  21. package/dest/metrics.d.ts +6 -2
  22. package/dest/metrics.d.ts.map +1 -1
  23. package/dest/metrics.js +15 -1
  24. package/dest/proposal_handler.d.ts +173 -0
  25. package/dest/proposal_handler.d.ts.map +1 -0
  26. package/dest/proposal_handler.js +1302 -0
  27. package/dest/validator.d.ts +39 -25
  28. package/dest/validator.d.ts.map +1 -1
  29. package/dest/validator.js +255 -272
  30. package/package.json +19 -19
  31. package/src/checkpoint_builder.ts +134 -37
  32. package/src/config.ts +49 -9
  33. package/src/duties/validation_service.ts +52 -54
  34. package/src/factory.ts +20 -8
  35. package/src/index.ts +1 -1
  36. package/src/key_store/ha_key_store.ts +1 -1
  37. package/src/key_store/web3signer_key_store.ts +43 -59
  38. package/src/metrics.ts +21 -1
  39. package/src/proposal_handler.ts +1418 -0
  40. package/src/validator.ts +348 -324
  41. package/dest/block_proposal_handler.d.ts +0 -63
  42. package/dest/block_proposal_handler.d.ts.map +0 -1
  43. package/dest/block_proposal_handler.js +0 -532
  44. package/src/block_proposal_handler.ts +0 -535
package/src/validator.ts CHANGED
@@ -1,74 +1,83 @@
1
1
  import type { BlobClientInterface } from '@aztec/blob-client/client';
2
2
  import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib';
3
3
  import type { EpochCache } from '@aztec/epoch-cache';
4
- import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
5
- import {
6
- BlockNumber,
7
- CheckpointNumber,
8
- EpochNumber,
9
- IndexWithinCheckpoint,
10
- SlotNumber,
11
- } from '@aztec/foundation/branded-types';
4
+ import { CheckpointNumber, EpochNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types';
12
5
  import { Fr } from '@aztec/foundation/curves/bn254';
13
- import { TimeoutError } from '@aztec/foundation/error';
14
6
  import type { EthAddress } from '@aztec/foundation/eth-address';
15
- import type { Signature } from '@aztec/foundation/eth-signature';
7
+ import { Signature } from '@aztec/foundation/eth-signature';
8
+ import { FifoSet } from '@aztec/foundation/fifo-set';
16
9
  import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
17
- import { retryUntil } from '@aztec/foundation/retry';
18
10
  import { RunningPromise } from '@aztec/foundation/running-promise';
19
11
  import { sleep } from '@aztec/foundation/sleep';
20
12
  import { DateProvider } from '@aztec/foundation/timer';
21
13
  import type { KeystoreManager } from '@aztec/node-keystore';
22
- import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
23
- import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
24
- import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
14
+ import type { DuplicateAttestationInfo, DuplicateProposalInfo, OversizedProposalInfo, P2P, PeerId } from '@aztec/p2p';
15
+ import { AuthRequest, AuthResponse, ReqRespSubProtocol } from '@aztec/p2p';
16
+ import {
17
+ OffenseType,
18
+ WANT_TO_CLEAR_SLASH_EVENT,
19
+ WANT_TO_SLASH_EVENT,
20
+ type Watcher,
21
+ type WatcherEmitter,
22
+ getOffenseTypeName,
23
+ } from '@aztec/slasher';
25
24
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
26
- import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
27
- import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
25
+ import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
26
+ import type { CheckpointReexecutionTracker } from '@aztec/stdlib/checkpoint';
27
+ import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
28
28
  import type {
29
- CreateCheckpointProposalLastBlockData,
30
29
  ITxProvider,
31
30
  Validator,
32
31
  ValidatorClientFullConfig,
33
32
  WorldStateSynchronizer,
34
33
  } from '@aztec/stdlib/interfaces/server';
35
- import { type L1ToL2MessageSource, accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging';
34
+ import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
36
35
  import {
37
36
  type BlockProposal,
38
37
  type BlockProposalOptions,
39
- type CheckpointAttestation,
38
+ CheckpointAttestation,
40
39
  CheckpointProposal,
41
40
  type CheckpointProposalCore,
42
41
  type CheckpointProposalOptions,
42
+ type CoordinationSignatureContext,
43
+ type ValidatedBlockProposal,
44
+ type ValidatedCheckpointProposalCore,
43
45
  } from '@aztec/stdlib/p2p';
44
46
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
45
- import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
47
+ import { ConsensusTimetable } from '@aztec/stdlib/timetable';
48
+ import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
46
49
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
47
50
  import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
48
- import { createHASigner } from '@aztec/validator-ha-signer/factory';
49
- import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
51
+ import {
52
+ createHASigner,
53
+ createLocalSignerWithProtection,
54
+ createSignerFromSharedDb,
55
+ } from '@aztec/validator-ha-signer/factory';
56
+ import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
50
57
  import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
51
58
 
52
59
  import { EventEmitter } from 'events';
53
60
  import type { TypedDataDefinition } from 'viem';
54
61
 
55
- import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
56
62
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
57
63
  import { ValidationService } from './duties/validation_service.js';
58
64
  import { HAKeyStore } from './key_store/ha_key_store.js';
59
65
  import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
60
66
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
61
67
  import { ValidatorMetrics } from './metrics.js';
68
+ import {
69
+ type BlockProposalValidationFailureReason,
70
+ type CheckpointProposalValidationFailureResult,
71
+ ProposalHandler,
72
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT,
73
+ SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT,
74
+ } from './proposal_handler.js';
62
75
 
63
76
  // We maintain a set of proposers who have proposed invalid blocks.
64
77
  // Just cap the set to avoid unbounded growth.
65
78
  const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
66
-
67
- // What errors from the block proposal handler result in slashing
68
- const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
69
- 'state_mismatch',
70
- 'failed_txs',
71
- ];
79
+ const MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS = 1000;
80
+ const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
72
81
 
73
82
  /**
74
83
  * Validator Client
@@ -92,7 +101,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
92
101
  /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
93
102
  private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
94
103
 
95
- private proposersOfInvalidBlocks: Set<string> = new Set();
104
+ private proposersOfInvalidBlocks = FifoSet.withLimit<string>(MAX_PROPOSERS_OF_INVALID_BLOCKS);
105
+ private invalidCheckpointProposalOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS);
106
+ private oversizedProposalOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS);
107
+ private badAttestationOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_BAD_ATTESTATIONS);
96
108
 
97
109
  /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
98
110
  private lastAttestedProposal?: CheckpointProposalCore;
@@ -101,14 +113,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
101
113
  private keyStore: ExtendedValidatorKeyStore,
102
114
  private epochCache: EpochCache,
103
115
  private p2pClient: P2P,
104
- private blockProposalHandler: BlockProposalHandler,
116
+ private proposalHandler: ProposalHandler,
105
117
  private blockSource: L2BlockSource,
106
118
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
107
119
  private worldState: WorldStateSynchronizer,
108
120
  private l1ToL2MessageSource: L1ToL2MessageSource,
109
121
  private config: ValidatorClientFullConfig,
110
122
  private blobClient: BlobClientInterface,
111
- private haSigner: ValidatorHASigner | undefined,
123
+ private slashingProtectionSigner: ValidatorHASigner,
112
124
  private dateProvider: DateProvider = new DateProvider(),
113
125
  telemetry: TelemetryClient = getTelemetryClient(),
114
126
  log = createLogger('validator'),
@@ -121,11 +133,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
121
133
  this.tracer = telemetry.getTracer('Validator');
122
134
  this.metrics = new ValidatorMetrics(telemetry);
123
135
 
124
- this.validationService = new ValidationService(keyStore, this.log.createChild('validation-service'));
136
+ this.validationService = new ValidationService(
137
+ keyStore,
138
+ this.getSignatureContext(),
139
+ this.log.createChild('validation-service'),
140
+ );
141
+ this.proposalHandler.setCheckpointProposalValidationFailureCallback((proposal, result, proposalInfo) =>
142
+ this.handleInvalidCheckpointProposal(proposal, result, proposalInfo),
143
+ );
125
144
 
126
145
  // Refresh epoch cache every second to trigger alert if participation in committee changes
127
146
  this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
128
-
129
147
  const myAddresses = this.getValidatorAddresses();
130
148
  this.log.verbose(`Initialized validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
131
149
  }
@@ -194,54 +212,74 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
194
212
  txProvider: ITxProvider,
195
213
  keyStoreManager: KeystoreManager,
196
214
  blobClient: BlobClientInterface,
215
+ reexecutionTracker: CheckpointReexecutionTracker,
197
216
  dateProvider: DateProvider = new DateProvider(),
198
217
  telemetry: TelemetryClient = getTelemetryClient(),
218
+ slashingProtectionDb?: SlashingProtectionDatabase,
199
219
  ) {
200
220
  const metrics = new ValidatorMetrics(telemetry);
201
- const blockProposalValidator = new BlockProposalValidator(epochCache, {
202
- txsPermitted: !config.disableTransactions,
203
- maxTxsPerBlock: config.maxTxsPerBlock,
221
+ const consensusTimetable = new ConsensusTimetable({
222
+ l1Constants: epochCache.getL1Constants(),
223
+ blockDuration: config.blockDurationMs / 1000,
204
224
  });
205
- const blockProposalHandler = new BlockProposalHandler(
225
+ const proposalHandler = new ProposalHandler(
206
226
  checkpointsBuilder,
207
227
  worldState,
208
228
  blockSource,
209
229
  l1ToL2MessageSource,
210
230
  txProvider,
211
- blockProposalValidator,
212
231
  epochCache,
232
+ consensusTimetable,
213
233
  config,
234
+ blobClient,
235
+ reexecutionTracker,
214
236
  metrics,
215
237
  dateProvider,
216
238
  telemetry,
239
+ undefined,
217
240
  );
218
241
 
219
242
  const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
220
- let validatorKeyStore: ExtendedValidatorKeyStore = nodeKeystoreAdapter;
221
- let haSigner: ValidatorHASigner | undefined;
222
- if (config.haSigningEnabled) {
243
+ let slashingProtectionSigner: ValidatorHASigner;
244
+ if (slashingProtectionDb) {
245
+ // Shared database mode: use a pre-existing database (e.g. for testing HA setups).
246
+ ({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
247
+ telemetryClient: telemetry,
248
+ dateProvider,
249
+ }));
250
+ } else if (config.haSigningEnabled) {
251
+ // Multi-node HA mode: use PostgreSQL-backed distributed locking.
223
252
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
224
253
  const haConfig = {
225
254
  ...config,
226
255
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
227
256
  };
228
- const { signer } = await createHASigner(haConfig, { telemetryClient: telemetry, dateProvider });
229
- haSigner = signer;
230
- validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
257
+ ({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
258
+ telemetryClient: telemetry,
259
+ dateProvider,
260
+ }));
261
+ } else {
262
+ // Single-node mode: use LMDB-backed local signing protection.
263
+ // This prevents double-signing if the node crashes and restarts mid-proposal.
264
+ ({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
265
+ telemetryClient: telemetry,
266
+ dateProvider,
267
+ }));
231
268
  }
269
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
232
270
 
233
271
  const validator = new ValidatorClient(
234
272
  validatorKeyStore,
235
273
  epochCache,
236
274
  p2pClient,
237
- blockProposalHandler,
275
+ proposalHandler,
238
276
  blockSource,
239
277
  checkpointsBuilder,
240
278
  worldState,
241
279
  l1ToL2MessageSource,
242
280
  config,
243
281
  blobClient,
244
- haSigner,
282
+ slashingProtectionSigner,
245
283
  dateProvider,
246
284
  telemetry,
247
285
  );
@@ -255,14 +293,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
255
293
  .filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
256
294
  }
257
295
 
258
- public getBlockProposalHandler() {
259
- return this.blockProposalHandler;
296
+ public getProposalHandler() {
297
+ return this.proposalHandler;
260
298
  }
261
299
 
262
300
  public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
263
301
  return this.keyStore.signTypedDataWithAddress(addr, msg, context);
264
302
  }
265
303
 
304
+ private getSignatureContext(): CoordinationSignatureContext {
305
+ return {
306
+ chainId: this.config.l1ChainId,
307
+ rollupAddress: this.config.rollupAddress,
308
+ };
309
+ }
310
+
266
311
  public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
267
312
  return this.keyStore.getCoinbaseAddress(attestor);
268
313
  }
@@ -275,30 +320,27 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
275
320
  return this.config;
276
321
  }
277
322
 
323
+ public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
324
+ return this.proposalHandler.hasProposalEquivocation(slotNumber);
325
+ }
326
+
327
+ public hasInvalidProposals(slotNumber: SlotNumber): boolean {
328
+ return this.proposalHandler.hasInvalidProposals(slotNumber);
329
+ }
330
+
278
331
  public updateConfig(config: Partial<ValidatorClientFullConfig>) {
279
332
  this.config = { ...this.config, ...config };
333
+ this.proposalHandler.updateConfig(config);
280
334
  }
281
335
 
282
336
  public reloadKeystore(newManager: KeystoreManager): void {
283
- if (this.config.haSigningEnabled && !this.haSigner) {
284
- this.log.warn(
285
- 'HA signing is enabled in config but was not initialized at startup. ' +
286
- 'Restart the node to enable HA signing.',
287
- );
288
- } else if (!this.config.haSigningEnabled && this.haSigner) {
289
- this.log.warn(
290
- 'HA signing was disabled via config update but the HA signer is still active. ' +
291
- 'Restart the node to fully disable HA signing.',
292
- );
293
- }
294
-
295
337
  const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
296
- if (this.haSigner) {
297
- this.keyStore = new HAKeyStore(newAdapter, this.haSigner);
298
- } else {
299
- this.keyStore = newAdapter;
300
- }
301
- this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
338
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
339
+ this.validationService = new ValidationService(
340
+ this.keyStore,
341
+ this.getSignatureContext(),
342
+ this.log.createChild('validation-service'),
343
+ );
302
344
  }
303
345
 
304
346
  public async start() {
@@ -334,7 +376,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
334
376
  this.log.debug(`Registering validator handlers for p2p client`);
335
377
 
336
378
  // Block proposal handler - validates but does NOT attest (validators only attest to checkpoints)
337
- const blockHandler = (block: BlockProposal, proposalSender: PeerId): Promise<boolean> =>
379
+ const blockHandler = (block: ValidatedBlockProposal, proposalSender: PeerId): Promise<boolean> =>
338
380
  this.validateBlockProposal(block, proposalSender);
339
381
  this.p2pClient.registerBlockProposalHandler(blockHandler);
340
382
 
@@ -342,21 +384,30 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
342
384
  // The checkpoint is received as CheckpointProposalCore since the lastBlock is extracted
343
385
  // and processed separately via the block handler above.
344
386
  const checkpointHandler = (
345
- checkpoint: CheckpointProposalCore,
387
+ checkpoint: ValidatedCheckpointProposalCore,
346
388
  proposalSender: PeerId,
347
389
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
348
- this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
390
+ this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
349
391
 
350
392
  // Duplicate proposal handler - triggers slashing for equivocation
351
393
  this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
352
394
  this.handleDuplicateProposal(info);
353
395
  });
354
396
 
397
+ // Oversized proposal handler - triggers slashing for proposals beyond the per-checkpoint block limit
398
+ this.p2pClient.registerOversizedProposalCallback((info: OversizedProposalInfo) => {
399
+ this.handleOversizedProposal(info);
400
+ });
401
+
355
402
  // Duplicate attestation handler - triggers slashing for attestation equivocation
356
403
  this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
357
404
  this.handleDuplicateAttestation(info);
358
405
  });
359
406
 
407
+ this.p2pClient.registerCheckpointAttestationCallback((attestation: CheckpointAttestation) => {
408
+ this.handleCheckpointAttestation(attestation);
409
+ });
410
+
360
411
  const myAddresses = this.getValidatorAddresses();
361
412
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
362
413
 
@@ -369,7 +420,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
369
420
  * Note: Validators do NOT attest to individual blocks - attestations are only for checkpoint proposals.
370
421
  * @returns true if the proposal is valid, false otherwise
371
422
  */
372
- async validateBlockProposal(proposal: BlockProposal, proposalSender: PeerId): Promise<boolean> {
423
+ async validateBlockProposal(proposal: ValidatedBlockProposal, proposalSender: PeerId): Promise<boolean> {
373
424
  const slotNumber = proposal.slotNumber;
374
425
 
375
426
  // Note: During escape hatch, we still want to "validate" proposals for observability,
@@ -384,13 +435,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
384
435
  return false;
385
436
  }
386
437
 
387
- // Ignore proposals from ourselves (may happen in HA setups)
438
+ // Log self-proposals from HA peers (same validator key on different nodes)
388
439
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
389
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
440
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
390
441
  proposer: proposer.toString(),
391
442
  slotNumber,
392
443
  });
393
- return false;
394
444
  }
395
445
 
396
446
  // Check if we're in the committee (for metrics purposes)
@@ -404,27 +454,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
404
454
  fishermanMode: this.config.fishermanMode || false,
405
455
  });
406
456
 
407
- // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
408
- // In fisherman mode, we always reexecute to validate proposals.
409
- const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
410
- this.config;
411
- const shouldReexecute =
412
- fishermanMode ||
413
- (slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
414
- (partOfCommittee && validatorReexecute) ||
415
- alwaysReexecuteBlockProposals ||
416
- this.blobClient.canUpload();
417
-
418
- const validationResult = await this.blockProposalHandler.handleBlockProposal(
419
- proposal,
420
- proposalSender,
421
- !!shouldReexecute && !escapeHatchOpen,
422
- );
457
+ // Reexecute outside the escape hatch so slashing observers can detect invalid proposals even when penalties are 0.
458
+ const validationResult = await this.proposalHandler.handleBlockProposal(proposal, proposalSender, !escapeHatchOpen);
423
459
 
424
460
  if (!validationResult.isValid) {
425
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
426
-
427
461
  const reason = validationResult.reason || 'unknown';
462
+
463
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
464
+
428
465
  // Classify failure reason: bad proposal vs node issue
429
466
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
430
467
  'invalid_proposal',
@@ -432,6 +469,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
432
469
  'failed_txs',
433
470
  'in_hash_mismatch',
434
471
  'parent_block_wrong_slot',
472
+ 'duplicate_txs',
473
+ 'invalid_embedded_txs',
435
474
  ];
436
475
 
437
476
  if (badProposalReasons.includes(reason as BlockProposalValidationFailureReason)) {
@@ -441,15 +480,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
441
480
  this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
442
481
  }
443
482
 
444
- // Slash invalid block proposals (can happen even when not in committee)
445
483
  if (
446
484
  !escapeHatchOpen &&
447
485
  validationResult.reason &&
448
- SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason) &&
449
- slashBroadcastedInvalidBlockPenalty > 0n
486
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)
450
487
  ) {
451
- this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo);
488
+ this.log.info(`Detected invalid block proposal offense`, {
489
+ ...proposalInfo,
490
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
491
+ offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL),
492
+ });
452
493
  this.slashInvalidBlock(proposal);
494
+ this.markInvalidProposalSlot(proposal.slotNumber);
453
495
  }
454
496
  return false;
455
497
  }
@@ -476,71 +518,59 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
476
518
  * @returns Checkpoint attestations if valid, undefined otherwise
477
519
  */
478
520
  async attestToCheckpointProposal(
479
- proposal: CheckpointProposalCore,
521
+ proposal: ValidatedCheckpointProposalCore,
480
522
  _proposalSender: PeerId,
481
523
  ): Promise<CheckpointAttestation[] | undefined> {
482
- const slotNumber = proposal.slotNumber;
524
+ const proposalSlotNumber = proposal.slotNumber;
483
525
  const proposer = proposal.getSender();
484
526
 
485
527
  // If escape hatch is open for this slot's epoch, do not attest.
486
- if (await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber)) {
487
- this.log.warn(`Escape hatch open for slot ${slotNumber}, skipping checkpoint attestation handling`);
528
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
529
+ this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
488
530
  return undefined;
489
531
  }
490
532
 
491
- // Reject proposals with invalid signatures
492
- if (!proposer) {
493
- this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
533
+ // Early-out for equivocation: refuses if we've already attested to a higher slot.
534
+ if (!this.shouldAttestToSlot(proposalSlotNumber)) {
494
535
  return undefined;
495
536
  }
496
537
 
497
538
  // Ignore proposals from ourselves (may happen in HA setups)
498
- if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
499
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
539
+ if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
540
+ this.log.debug(`Not attesting to block proposal from self for slot ${proposalSlotNumber}`, {
500
541
  proposer: proposer.toString(),
501
- slotNumber,
542
+ proposalSlotNumber,
502
543
  });
503
544
  return undefined;
504
545
  }
505
546
 
506
- // Validate fee asset price modifier is within allowed range
507
- if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
508
- this.log.warn(
509
- `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`,
510
- );
511
- return undefined;
512
- }
513
-
514
- // Check that I have any address in current committee before attesting
515
- const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
547
+ // Check that I have any address in the committee where this checkpoint will land before attesting
548
+ const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
516
549
  const partOfCommittee = inCommittee.length > 0;
517
550
 
518
551
  const proposalInfo = {
519
- slotNumber,
552
+ proposalSlotNumber,
520
553
  archive: proposal.archive.toString(),
521
- proposer: proposer.toString(),
522
- txCount: proposal.txHashes.length,
554
+ proposer: proposer?.toString(),
523
555
  };
524
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
556
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
525
557
  ...proposalInfo,
526
- txHashes: proposal.txHashes.map(t => t.toString()),
527
558
  fishermanMode: this.config.fishermanMode || false,
528
559
  });
529
560
 
530
- // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
561
+ // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
562
+ // Uses the cached result from the all-nodes callback if available (avoids double validation).
563
+ let checkpointNumber: CheckpointNumber;
531
564
  if (this.config.skipCheckpointProposalValidation) {
532
- this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
565
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
566
+ checkpointNumber = CheckpointNumber(0);
533
567
  } else {
534
- const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
568
+ const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
535
569
  if (!validationResult.isValid) {
536
570
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
537
571
  return undefined;
538
572
  }
539
- }
540
-
541
- // Upload blobs to filestore if we can (fire and forget)
542
- if (this.blobClient.canUpload()) {
543
- void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
573
+ checkpointNumber = validationResult.checkpointNumber;
544
574
  }
545
575
 
546
576
  // Check that I have any address in current committee before attesting
@@ -551,16 +581,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
551
581
  }
552
582
 
553
583
  // Provided all of the above checks pass, we can attest to the proposal
554
- this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, {
555
- ...proposalInfo,
556
- inCommittee: partOfCommittee,
557
- fishermanMode: this.config.fishermanMode || false,
558
- });
584
+ this.log.info(
585
+ `${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
586
+ {
587
+ ...proposalInfo,
588
+ inCommittee: partOfCommittee,
589
+ fishermanMode: this.config.fishermanMode || false,
590
+ },
591
+ );
559
592
 
560
593
  this.metrics.incSuccessfulAttestations(inCommittee.length);
561
594
 
562
595
  // Track epoch participation per attester: count each (attester, epoch) pair at most once
563
- const proposalEpoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants());
596
+ const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
564
597
  for (const attester of inCommittee) {
565
598
  const key = attester.toString();
566
599
  const lastEpoch = this.lastAttestedEpochByAttester.get(key);
@@ -588,14 +621,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
588
621
 
589
622
  if (this.config.fishermanMode) {
590
623
  // bail out early and don't save attestations to the pool in fisherman mode
591
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
624
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
592
625
  ...proposalInfo,
593
626
  attestors: attestors.map(a => a.toString()),
594
627
  });
595
628
  return undefined;
596
629
  }
597
630
 
598
- return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
631
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
599
632
  }
600
633
 
601
634
  /**
@@ -622,13 +655,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
622
655
  private async createCheckpointAttestationsFromProposal(
623
656
  proposal: CheckpointProposalCore,
624
657
  attestors: EthAddress[] = [],
658
+ checkpointNumber: CheckpointNumber,
625
659
  ): Promise<CheckpointAttestation[] | undefined> {
626
660
  // Equivocation check: must happen right before signing to minimize the race window
627
661
  if (!this.shouldAttestToSlot(proposal.slotNumber)) {
628
662
  return undefined;
629
663
  }
630
664
 
631
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
665
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
632
666
 
633
667
  // Track the proposal we attested to (to prevent equivocation)
634
668
  this.lastAttestedProposal = proposal;
@@ -637,164 +671,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
637
671
  return attestations;
638
672
  }
639
673
 
640
- /**
641
- * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
642
- * @returns Validation result with isValid flag and reason if invalid.
643
- */
644
- private async validateCheckpointProposal(
645
- proposal: CheckpointProposalCore,
646
- proposalInfo: LogData,
647
- ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
648
- const slot = proposal.slotNumber;
649
-
650
- // Timeout block syncing at the start of the next slot
651
- const config = this.checkpointsBuilder.getConfig();
652
- const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
653
- const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
654
-
655
- // Wait for last block to sync by archive
656
- let lastBlockHeader: BlockHeader | undefined;
657
- try {
658
- lastBlockHeader = await retryUntil(
659
- async () => {
660
- await this.blockSource.syncImmediate();
661
- return this.blockSource.getBlockHeaderByArchive(proposal.archive);
662
- },
663
- `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
664
- timeoutSeconds,
665
- 0.5,
666
- );
667
- } catch (err) {
668
- if (err instanceof TimeoutError) {
669
- this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
670
- return { isValid: false, reason: 'last_block_not_found' };
671
- }
672
- this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
673
- return { isValid: false, reason: 'block_fetch_error' };
674
- }
675
-
676
- if (!lastBlockHeader) {
677
- this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
678
- return { isValid: false, reason: 'last_block_not_found' };
679
- }
680
-
681
- // Get all full blocks for the slot and checkpoint
682
- const blocks = await this.blockSource.getBlocksForSlot(slot);
683
- if (blocks.length === 0) {
684
- this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
685
- return { isValid: false, reason: 'no_blocks_for_slot' };
686
- }
687
-
688
- // Ensure the last block for this slot matches the archive in the checkpoint proposal
689
- if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
690
- this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
691
- return { isValid: false, reason: 'last_block_archive_mismatch' };
692
- }
693
-
694
- this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
695
- ...proposalInfo,
696
- blockNumbers: blocks.map(b => b.number),
697
- });
698
-
699
- // Get checkpoint constants from first block
700
- const firstBlock = blocks[0];
701
- const constants = this.extractCheckpointConstants(firstBlock);
702
- const checkpointNumber = firstBlock.checkpointNumber;
703
-
704
- // Get L1-to-L2 messages for this checkpoint
705
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
706
-
707
- // Collect the out hashes of all the checkpoints before this one in the same epoch
708
- const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
709
- const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
710
- .filter(c => c.checkpointNumber < checkpointNumber)
711
- .map(c => c.checkpointOutHash);
712
-
713
- // Fork world state at the block before the first block
714
- const parentBlockNumber = BlockNumber(firstBlock.number - 1);
715
- const fork = await this.worldState.fork(parentBlockNumber);
716
-
717
- try {
718
- // Create checkpoint builder with all existing blocks
719
- const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
720
- checkpointNumber,
721
- constants,
722
- proposal.feeAssetPriceModifier,
723
- l1ToL2Messages,
724
- previousCheckpointOutHashes,
725
- fork,
726
- blocks,
727
- this.log.getBindings(),
728
- );
729
-
730
- // Complete the checkpoint to get computed values
731
- const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
732
-
733
- // Compare checkpoint header with proposal
734
- if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
735
- this.log.warn(`Checkpoint header mismatch`, {
736
- ...proposalInfo,
737
- computed: computedCheckpoint.header.toInspect(),
738
- proposal: proposal.checkpointHeader.toInspect(),
739
- });
740
- return { isValid: false, reason: 'checkpoint_header_mismatch' };
741
- }
742
-
743
- // Compare archive root with proposal
744
- if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
745
- this.log.warn(`Archive root mismatch`, {
746
- ...proposalInfo,
747
- computed: computedCheckpoint.archive.root.toString(),
748
- proposal: proposal.archive.toString(),
749
- });
750
- return { isValid: false, reason: 'archive_mismatch' };
751
- }
752
-
753
- // Check that the accumulated epoch out hash matches the value in the proposal.
754
- // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
755
- const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
756
- const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
757
- const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
758
- if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
759
- this.log.warn(`Epoch out hash mismatch`, {
760
- proposalEpochOutHash: proposalEpochOutHash.toString(),
761
- computedEpochOutHash: computedEpochOutHash.toString(),
762
- checkpointOutHash: checkpointOutHash.toString(),
763
- previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
764
- ...proposalInfo,
765
- });
766
- return { isValid: false, reason: 'out_hash_mismatch' };
767
- }
768
-
769
- this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
770
- return { isValid: true };
771
- } finally {
772
- await fork.close();
773
- }
774
- }
775
-
776
- /**
777
- * Extract checkpoint global variables from a block.
778
- */
779
- private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
780
- const gv = block.header.globalVariables;
781
- return {
782
- chainId: gv.chainId,
783
- version: gv.version,
784
- slotNumber: gv.slotNumber,
785
- timestamp: gv.timestamp,
786
- coinbase: gv.coinbase,
787
- feeRecipient: gv.feeRecipient,
788
- gasFees: gv.gasFees,
789
- };
790
- }
791
-
792
674
  /**
793
675
  * Uploads blobs for a checkpoint to the filestore (fire and forget).
794
676
  */
795
677
  protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
796
678
  try {
797
- const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
679
+ const lastBlockHeader = (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header;
798
680
  if (!lastBlockHeader) {
799
681
  this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
800
682
  return;
@@ -827,12 +709,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
827
709
  return;
828
710
  }
829
711
 
830
- // Trim the set if it's too big.
831
- if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
832
- // remove oldest proposer. `values` is guaranteed to be in insertion order.
833
- this.proposersOfInvalidBlocks.delete(this.proposersOfInvalidBlocks.values().next().value!);
834
- }
835
-
836
712
  this.proposersOfInvalidBlocks.add(proposer.toString());
837
713
 
838
714
  this.emit(WANT_TO_SLASH_EVENT, [
@@ -845,20 +721,148 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
845
721
  ]);
846
722
  }
847
723
 
724
+ private handleInvalidCheckpointProposal(
725
+ proposal: CheckpointProposalCore,
726
+ result: CheckpointProposalValidationFailureResult,
727
+ proposalInfo: LogData,
728
+ ): void {
729
+ if (!SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
730
+ return;
731
+ }
732
+
733
+ // The slot is already marked invalid by the all-nodes checkpoint handler that invokes this callback,
734
+ // so we only emit the proposer slash event here.
735
+ if (this.slashInvalidCheckpointProposal(proposal)) {
736
+ this.log.info(`Detected invalid checkpoint proposal offense`, {
737
+ ...proposalInfo,
738
+ reason: result.reason,
739
+ amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
740
+ offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL),
741
+ });
742
+ }
743
+ }
744
+
745
+ private slashInvalidCheckpointProposal(proposal: CheckpointProposalCore): boolean {
746
+ const proposer = proposal.getSender();
747
+ if (!proposer) {
748
+ this.log.warn(`Cannot slash checkpoint proposal with invalid signature`, {
749
+ slotNumber: proposal.slotNumber,
750
+ archive: proposal.archive.toString(),
751
+ });
752
+ return false;
753
+ }
754
+
755
+ const offenseType = OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL;
756
+ const offenseKey = `${proposer.toString()}:${offenseType}:${proposal.slotNumber}`;
757
+ if (!this.invalidCheckpointProposalOffenseKeys.addIfAbsent(offenseKey)) {
758
+ return false;
759
+ }
760
+
761
+ this.emit(WANT_TO_SLASH_EVENT, [
762
+ {
763
+ validator: proposer,
764
+ amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
765
+ offenseType,
766
+ epochOrSlot: BigInt(proposal.slotNumber),
767
+ },
768
+ ]);
769
+ return true;
770
+ }
771
+
772
+ private markInvalidProposalSlot(slotNumber: SlotNumber): void {
773
+ this.proposalHandler.markInvalidProposalSlot(slotNumber);
774
+ }
775
+
776
+ private handleCheckpointAttestation(attestation: CheckpointAttestation): void {
777
+ const slotNumber = attestation.slotNumber;
778
+ if (
779
+ !this.proposalHandler.hasInvalidProposals(slotNumber) ||
780
+ this.proposalHandler.hasProposalEquivocation(slotNumber)
781
+ ) {
782
+ return;
783
+ }
784
+
785
+ const attester = attestation.getSender();
786
+ if (!attester) {
787
+ this.log.warn(`Cannot slash checkpoint attestation with invalid signature`, {
788
+ slotNumber,
789
+ archive: attestation.archive.toString(),
790
+ });
791
+ return;
792
+ }
793
+
794
+ this.slashAttestedToInvalidCheckpointProposal(slotNumber, attester);
795
+ }
796
+
797
+ private slashAttestedToInvalidCheckpointProposal(slotNumber: SlotNumber, attester: EthAddress): void {
798
+ const offenseKey = `${attester.toString()}:${OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL}:${slotNumber}`;
799
+ if (!this.badAttestationOffenseKeys.addIfAbsent(offenseKey)) {
800
+ return;
801
+ }
802
+
803
+ this.log.info(`Detected attestation to invalid checkpoint proposal offense`, {
804
+ attester: attester.toString(),
805
+ slotNumber,
806
+ amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
807
+ offenseType: getOffenseTypeName(OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL),
808
+ });
809
+
810
+ this.emit(WANT_TO_SLASH_EVENT, [
811
+ {
812
+ validator: attester,
813
+ amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
814
+ offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
815
+ epochOrSlot: BigInt(slotNumber),
816
+ },
817
+ ]);
818
+ }
819
+
820
+ /**
821
+ * Handle detection of an oversized block proposal: one whose index within its checkpoint lands at or
822
+ * beyond the consensus per-checkpoint block limit. A single signed proposal at an illegal index is
823
+ * self-contained evidence, so emit an invalid-block-proposal slash event for the proposer, deduped per
824
+ * (proposer, slot) since the p2p layer reports every oversized proposal it stores.
825
+ */
826
+ private handleOversizedProposal(info: OversizedProposalInfo): void {
827
+ const { slot, proposer } = info;
828
+ const offenseType = OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL;
829
+ if (!this.oversizedProposalOffenseKeys.addIfAbsent(`${proposer.toString()}:${offenseType}:${slot}`)) {
830
+ return;
831
+ }
832
+
833
+ this.log.info(`Detected oversized block proposal offense from ${proposer.toString()} at slot ${slot}`, {
834
+ proposer: proposer.toString(),
835
+ slot,
836
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
837
+ offenseType: getOffenseTypeName(offenseType),
838
+ });
839
+
840
+ this.emit(WANT_TO_SLASH_EVENT, [
841
+ {
842
+ validator: proposer,
843
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
844
+ offenseType,
845
+ epochOrSlot: BigInt(slot),
846
+ },
847
+ ]);
848
+ }
849
+
848
850
  /**
849
851
  * Handle detection of a duplicate proposal (equivocation).
850
852
  * Emits a slash event when a proposer sends multiple proposals for the same position.
851
853
  */
852
854
  private handleDuplicateProposal(info: DuplicateProposalInfo): void {
853
855
  const { slot, proposer, type } = info;
856
+ this.proposalHandler.markProposalEquivocation(slot);
854
857
 
855
- this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
858
+ this.log.info(`Detected duplicate ${type} proposal offense from ${proposer.toString()} at slot ${slot}`, {
856
859
  proposer: proposer.toString(),
857
860
  slot,
858
861
  type,
862
+ amount: this.config.slashDuplicateProposalPenalty,
863
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_PROPOSAL),
859
864
  });
860
865
 
861
- // Emit slash event
862
866
  this.emit(WANT_TO_SLASH_EVENT, [
863
867
  {
864
868
  validator: proposer,
@@ -867,6 +871,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
867
871
  epochOrSlot: BigInt(slot),
868
872
  },
869
873
  ]);
874
+
875
+ this.emit(WANT_TO_CLEAR_SLASH_EVENT, [
876
+ {
877
+ offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
878
+ epochOrSlot: BigInt(slot),
879
+ },
880
+ ]);
870
881
  }
871
882
 
872
883
  /**
@@ -876,9 +887,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
876
887
  private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
877
888
  const { slot, attester } = info;
878
889
 
879
- this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
890
+ this.log.info(`Detected duplicate attestation offense from ${attester.toString()} at slot ${slot}`, {
880
891
  attester: attester.toString(),
881
892
  slot,
893
+ amount: this.config.slashDuplicateAttestationPenalty,
894
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_ATTESTATION),
882
895
  });
883
896
 
884
897
  this.emit(WANT_TO_SLASH_EVENT, [
@@ -893,6 +906,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
893
906
 
894
907
  async createBlockProposal(
895
908
  blockHeader: BlockHeader,
909
+ checkpointNumber: CheckpointNumber,
896
910
  indexWithinCheckpoint: IndexWithinCheckpoint,
897
911
  inHash: Fr,
898
912
  archive: Fr,
@@ -919,6 +933,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
919
933
  );
920
934
  const newProposal = await this.validationService.createBlockProposal(
921
935
  blockHeader,
936
+ checkpointNumber,
922
937
  indexWithinCheckpoint,
923
938
  inHash,
924
939
  archive,
@@ -926,7 +941,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
926
941
  proposerAddress,
927
942
  {
928
943
  ...options,
929
- broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
944
+ broadcastInvalidBlockProposal:
945
+ options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal,
930
946
  },
931
947
  );
932
948
  this.lastProposedBlock = newProposal;
@@ -936,8 +952,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
936
952
  async createCheckpointProposal(
937
953
  checkpointHeader: CheckpointHeader,
938
954
  archive: Fr,
955
+ checkpointNumber: CheckpointNumber,
939
956
  feeAssetPriceModifier: bigint,
940
- lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
957
+ lastBlockProposal: BlockProposal | undefined,
941
958
  proposerAddress: EthAddress | undefined,
942
959
  options: CheckpointProposalOptions = {},
943
960
  ): Promise<CheckpointProposal> {
@@ -958,12 +975,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
958
975
  const newProposal = await this.validationService.createCheckpointProposal(
959
976
  checkpointHeader,
960
977
  archive,
978
+ checkpointNumber,
961
979
  feeAssetPriceModifier,
962
- lastBlockInfo,
980
+ lastBlockProposal,
963
981
  proposerAddress,
964
982
  options,
965
983
  );
966
984
  this.lastProposedCheckpoint = newProposal;
985
+ // Self-record this slot's outcome on the re-execution tracker. Proposers don't run their
986
+ // own proposals through `handleCheckpointProposal`, so without this call the proposer's
987
+ // sentinel would see no outcome for slots it proposed and would mis-attribute itself as
988
+ // inactive. We pass the locally-computed `archive` (not `newProposal.archive`, which may
989
+ // be intentionally corrupted under test-only flags); from the proposer's local-view
990
+ // perspective the work it just completed is valid by definition.
991
+ this.proposalHandler.recordOwnCheckpointProposalAsValid(checkpointHeader.slotNumber, archive, checkpointNumber);
967
992
  return newProposal;
968
993
  }
969
994
 
@@ -975,16 +1000,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
975
1000
  attestationsAndSigners: CommitteeAttestationsAndSigners,
976
1001
  proposer: EthAddress,
977
1002
  slot: SlotNumber,
978
- blockNumber: BlockNumber | CheckpointNumber,
1003
+ checkpointNumber: CheckpointNumber,
979
1004
  ): Promise<Signature> {
980
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
1005
+ return await this.validationService.signAttestationsAndSigners(
1006
+ attestationsAndSigners,
1007
+ proposer,
1008
+ slot,
1009
+ checkpointNumber,
1010
+ );
981
1011
  }
982
1012
 
983
- async collectOwnAttestations(proposal: CheckpointProposal): Promise<CheckpointAttestation[]> {
1013
+ async collectOwnAttestations(
1014
+ proposal: CheckpointProposal,
1015
+ checkpointNumber: CheckpointNumber,
1016
+ ): Promise<CheckpointAttestation[]> {
984
1017
  const slot = proposal.slotNumber;
985
1018
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
986
1019
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
987
- const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
1020
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
988
1021
 
989
1022
  if (!attestations) {
990
1023
  return [];
@@ -1003,6 +1036,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
1003
1036
  proposal: CheckpointProposal,
1004
1037
  required: number,
1005
1038
  deadline: Date,
1039
+ checkpointNumber: CheckpointNumber,
1006
1040
  ): Promise<CheckpointAttestation[]> {
1007
1041
  // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
1008
1042
  const slot = proposal.slotNumber;
@@ -1015,33 +1049,23 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
1015
1049
  throw new AttestationTimeoutError(0, required, slot);
1016
1050
  }
1017
1051
 
1018
- await this.collectOwnAttestations(proposal);
1052
+ await this.collectOwnAttestations(proposal, checkpointNumber);
1019
1053
 
1020
- const proposalId = proposal.archive.toString();
1054
+ const proposalPayloadHash = proposal.getPayloadHash();
1021
1055
  const myAddresses = this.getValidatorAddresses();
1022
1056
 
1023
1057
  let attestations: CheckpointAttestation[] = [];
1024
1058
  while (true) {
1025
- // Filter out attestations with a mismatching archive. This should NOT happen since we have verified
1026
- // the proposer signature (ie our own) before accepting the attestation into the pool via the p2p client.
1027
- const collectedAttestations = (await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalId)).filter(
1028
- attestation => {
1029
- if (!attestation.archive.equals(proposal.archive)) {
1030
- this.log.warn(
1031
- `Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`,
1032
- { attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() },
1033
- );
1034
- return false;
1035
- }
1036
- return true;
1037
- },
1038
- );
1059
+ // The pool already filters by proposal payload hash; if any attestation slips through with a
1060
+ // mismatched payload hash, drop it defensively. Equivocations are emitted as separate slash
1061
+ // events from libp2p_service.
1062
+ const collectedAttestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalPayloadHash);
1039
1063
 
1040
1064
  // Log new attestations we collected
1041
1065
  const oldSenders = attestations.map(attestation => attestation.getSender());
1042
1066
  for (const collected of collectedAttestations) {
1043
1067
  const collectedSender = collected.getSender();
1044
- // Skip attestations with invalid signatures
1068
+ // Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
1045
1069
  if (!collectedSender) {
1046
1070
  this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
1047
1071
  continue;