@aztec/validator-client 0.0.1-commit.42ee6df9b → 0.0.1-commit.431c48d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/validator.ts CHANGED
@@ -1,29 +1,31 @@
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 {
5
- BlockNumber,
6
- CheckpointNumber,
7
- EpochNumber,
8
- IndexWithinCheckpoint,
9
- SlotNumber,
10
- } from '@aztec/foundation/branded-types';
4
+ import { CheckpointNumber, EpochNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types';
11
5
  import { Fr } from '@aztec/foundation/curves/bn254';
12
6
  import type { EthAddress } from '@aztec/foundation/eth-address';
13
- import type { Signature } from '@aztec/foundation/eth-signature';
7
+ import { Signature } from '@aztec/foundation/eth-signature';
8
+ import { FifoSet } from '@aztec/foundation/fifo-set';
14
9
  import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
15
10
  import { RunningPromise } from '@aztec/foundation/running-promise';
16
11
  import { sleep } from '@aztec/foundation/sleep';
17
12
  import { DateProvider } from '@aztec/foundation/timer';
18
13
  import type { KeystoreManager } from '@aztec/node-keystore';
19
- import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
20
- import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
21
- 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';
22
24
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
23
25
  import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
26
+ import type { CheckpointReexecutionTracker } from '@aztec/stdlib/checkpoint';
24
27
  import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
25
28
  import type {
26
- CreateCheckpointProposalLastBlockData,
27
29
  ITxProvider,
28
30
  Validator,
29
31
  ValidatorClientFullConfig,
@@ -33,12 +35,16 @@ import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
33
35
  import {
34
36
  type BlockProposal,
35
37
  type BlockProposalOptions,
36
- type CheckpointAttestation,
38
+ CheckpointAttestation,
37
39
  CheckpointProposal,
38
40
  type CheckpointProposalCore,
39
41
  type CheckpointProposalOptions,
42
+ type CoordinationSignatureContext,
43
+ type ValidatedBlockProposal,
44
+ type ValidatedCheckpointProposalCore,
40
45
  } from '@aztec/stdlib/p2p';
41
46
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
47
+ import { ConsensusTimetable } from '@aztec/stdlib/timetable';
42
48
  import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
43
49
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
44
50
  import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
@@ -59,17 +65,19 @@ 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';
62
- import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.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';
63
75
 
64
76
  // We maintain a set of proposers who have proposed invalid blocks.
65
77
  // Just cap the set to avoid unbounded growth.
66
78
  const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
67
-
68
- // What errors from the block proposal handler result in slashing
69
- const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
70
- 'state_mismatch',
71
- 'failed_txs',
72
- ];
79
+ const MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS = 1000;
80
+ const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
73
81
 
74
82
  /**
75
83
  * Validator Client
@@ -93,7 +101,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
93
101
  /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
94
102
  private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
95
103
 
96
- 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);
97
108
 
98
109
  /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
99
110
  private lastAttestedProposal?: CheckpointProposalCore;
@@ -122,11 +133,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
122
133
  this.tracer = telemetry.getTracer('Validator');
123
134
  this.metrics = new ValidatorMetrics(telemetry);
124
135
 
125
- 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
+ );
126
144
 
127
145
  // Refresh epoch cache every second to trigger alert if participation in committee changes
128
146
  this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
129
-
130
147
  const myAddresses = this.getValidatorAddresses();
131
148
  this.log.verbose(`Initialized validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
132
149
  }
@@ -195,14 +212,15 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
195
212
  txProvider: ITxProvider,
196
213
  keyStoreManager: KeystoreManager,
197
214
  blobClient: BlobClientInterface,
215
+ reexecutionTracker: CheckpointReexecutionTracker,
198
216
  dateProvider: DateProvider = new DateProvider(),
199
217
  telemetry: TelemetryClient = getTelemetryClient(),
200
218
  slashingProtectionDb?: SlashingProtectionDatabase,
201
219
  ) {
202
220
  const metrics = new ValidatorMetrics(telemetry);
203
- const blockProposalValidator = new BlockProposalValidator(epochCache, {
204
- txsPermitted: !config.disableTransactions,
205
- maxTxsPerBlock: config.validateMaxTxsPerBlock,
221
+ const consensusTimetable = new ConsensusTimetable({
222
+ l1Constants: epochCache.getL1Constants(),
223
+ blockDuration: config.blockDurationMs / 1000,
206
224
  });
207
225
  const proposalHandler = new ProposalHandler(
208
226
  checkpointsBuilder,
@@ -210,13 +228,15 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
210
228
  blockSource,
211
229
  l1ToL2MessageSource,
212
230
  txProvider,
213
- blockProposalValidator,
214
231
  epochCache,
232
+ consensusTimetable,
215
233
  config,
216
234
  blobClient,
235
+ reexecutionTracker,
217
236
  metrics,
218
237
  dateProvider,
219
238
  telemetry,
239
+ undefined,
220
240
  );
221
241
 
222
242
  const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
@@ -281,6 +301,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
281
301
  return this.keyStore.signTypedDataWithAddress(addr, msg, context);
282
302
  }
283
303
 
304
+ private getSignatureContext(): CoordinationSignatureContext {
305
+ return {
306
+ chainId: this.config.l1ChainId,
307
+ rollupAddress: this.config.rollupAddress,
308
+ };
309
+ }
310
+
284
311
  public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
285
312
  return this.keyStore.getCoinbaseAddress(attestor);
286
313
  }
@@ -293,14 +320,27 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
293
320
  return this.config;
294
321
  }
295
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
+
296
331
  public updateConfig(config: Partial<ValidatorClientFullConfig>) {
297
332
  this.config = { ...this.config, ...config };
333
+ this.proposalHandler.updateConfig(config);
298
334
  }
299
335
 
300
336
  public reloadKeystore(newManager: KeystoreManager): void {
301
337
  const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
302
338
  this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
303
- this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
339
+ this.validationService = new ValidationService(
340
+ this.keyStore,
341
+ this.getSignatureContext(),
342
+ this.log.createChild('validation-service'),
343
+ );
304
344
  }
305
345
 
306
346
  public async start() {
@@ -336,7 +376,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
336
376
  this.log.debug(`Registering validator handlers for p2p client`);
337
377
 
338
378
  // Block proposal handler - validates but does NOT attest (validators only attest to checkpoints)
339
- const blockHandler = (block: BlockProposal, proposalSender: PeerId): Promise<boolean> =>
379
+ const blockHandler = (block: ValidatedBlockProposal, proposalSender: PeerId): Promise<boolean> =>
340
380
  this.validateBlockProposal(block, proposalSender);
341
381
  this.p2pClient.registerBlockProposalHandler(blockHandler);
342
382
 
@@ -344,7 +384,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
344
384
  // The checkpoint is received as CheckpointProposalCore since the lastBlock is extracted
345
385
  // and processed separately via the block handler above.
346
386
  const checkpointHandler = (
347
- checkpoint: CheckpointProposalCore,
387
+ checkpoint: ValidatedCheckpointProposalCore,
348
388
  proposalSender: PeerId,
349
389
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
350
390
  this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
@@ -354,11 +394,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
354
394
  this.handleDuplicateProposal(info);
355
395
  });
356
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
+
357
402
  // Duplicate attestation handler - triggers slashing for attestation equivocation
358
403
  this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
359
404
  this.handleDuplicateAttestation(info);
360
405
  });
361
406
 
407
+ this.p2pClient.registerCheckpointAttestationCallback((attestation: CheckpointAttestation) => {
408
+ this.handleCheckpointAttestation(attestation);
409
+ });
410
+
362
411
  const myAddresses = this.getValidatorAddresses();
363
412
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
364
413
 
@@ -371,7 +420,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
371
420
  * Note: Validators do NOT attest to individual blocks - attestations are only for checkpoint proposals.
372
421
  * @returns true if the proposal is valid, false otherwise
373
422
  */
374
- async validateBlockProposal(proposal: BlockProposal, proposalSender: PeerId): Promise<boolean> {
423
+ async validateBlockProposal(proposal: ValidatedBlockProposal, proposalSender: PeerId): Promise<boolean> {
375
424
  const slotNumber = proposal.slotNumber;
376
425
 
377
426
  // Note: During escape hatch, we still want to "validate" proposals for observability,
@@ -405,21 +454,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
405
454
  fishermanMode: this.config.fishermanMode || false,
406
455
  });
407
456
 
408
- // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
409
- // In fisherman mode, we always reexecute to validate proposals.
410
- const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
411
- const shouldReexecute =
412
- fishermanMode ||
413
- slashBroadcastedInvalidBlockPenalty > 0n ||
414
- partOfCommittee ||
415
- alwaysReexecuteBlockProposals ||
416
- this.blobClient.canUpload();
417
-
418
- const validationResult = await this.proposalHandler.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
461
  const reason = validationResult.reason || 'unknown';
@@ -433,6 +469,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
433
469
  'failed_txs',
434
470
  'in_hash_mismatch',
435
471
  'parent_block_wrong_slot',
472
+ 'duplicate_txs',
473
+ 'invalid_embedded_txs',
436
474
  ];
437
475
 
438
476
  if (badProposalReasons.includes(reason as BlockProposalValidationFailureReason)) {
@@ -442,15 +480,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
442
480
  this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
443
481
  }
444
482
 
445
- // Slash invalid block proposals (can happen even when not in committee)
446
483
  if (
447
484
  !escapeHatchOpen &&
448
485
  validationResult.reason &&
449
- SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason) &&
450
- slashBroadcastedInvalidBlockPenalty > 0n
486
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)
451
487
  ) {
452
- 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
+ });
453
493
  this.slashInvalidBlock(proposal);
494
+ this.markInvalidProposalSlot(proposal.slotNumber);
454
495
  }
455
496
  return false;
456
497
  }
@@ -477,7 +518,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
477
518
  * @returns Checkpoint attestations if valid, undefined otherwise
478
519
  */
479
520
  async attestToCheckpointProposal(
480
- proposal: CheckpointProposalCore,
521
+ proposal: ValidatedCheckpointProposalCore,
481
522
  _proposalSender: PeerId,
482
523
  ): Promise<CheckpointAttestation[] | undefined> {
483
524
  const proposalSlotNumber = proposal.slotNumber;
@@ -489,9 +530,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
489
530
  return undefined;
490
531
  }
491
532
 
533
+ // Early-out for equivocation: refuses if we've already attested to a higher slot.
534
+ if (!this.shouldAttestToSlot(proposalSlotNumber)) {
535
+ return undefined;
536
+ }
537
+
492
538
  // Ignore proposals from ourselves (may happen in HA setups)
493
539
  if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
494
- this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
540
+ this.log.debug(`Not attesting to block proposal from self for slot ${proposalSlotNumber}`, {
495
541
  proposer: proposer.toString(),
496
542
  proposalSlotNumber,
497
543
  });
@@ -514,14 +560,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
514
560
 
515
561
  // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
516
562
  // Uses the cached result from the all-nodes callback if available (avoids double validation).
563
+ let checkpointNumber: CheckpointNumber;
517
564
  if (this.config.skipCheckpointProposalValidation) {
518
565
  this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
566
+ checkpointNumber = CheckpointNumber(0);
519
567
  } else {
520
568
  const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
521
569
  if (!validationResult.isValid) {
522
570
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
523
571
  return undefined;
524
572
  }
573
+ checkpointNumber = validationResult.checkpointNumber;
525
574
  }
526
575
 
527
576
  // Check that I have any address in current committee before attesting
@@ -579,7 +628,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
579
628
  return undefined;
580
629
  }
581
630
 
582
- return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
631
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
583
632
  }
584
633
 
585
634
  /**
@@ -606,13 +655,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
606
655
  private async createCheckpointAttestationsFromProposal(
607
656
  proposal: CheckpointProposalCore,
608
657
  attestors: EthAddress[] = [],
658
+ checkpointNumber: CheckpointNumber,
609
659
  ): Promise<CheckpointAttestation[] | undefined> {
610
660
  // Equivocation check: must happen right before signing to minimize the race window
611
661
  if (!this.shouldAttestToSlot(proposal.slotNumber)) {
612
662
  return undefined;
613
663
  }
614
664
 
615
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
665
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
616
666
 
617
667
  // Track the proposal we attested to (to prevent equivocation)
618
668
  this.lastAttestedProposal = proposal;
@@ -626,7 +676,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
626
676
  */
627
677
  protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
628
678
  try {
629
- const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
679
+ const lastBlockHeader = (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header;
630
680
  if (!lastBlockHeader) {
631
681
  this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
632
682
  return;
@@ -659,12 +709,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
659
709
  return;
660
710
  }
661
711
 
662
- // Trim the set if it's too big.
663
- if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
664
- // remove oldest proposer. `values` is guaranteed to be in insertion order.
665
- this.proposersOfInvalidBlocks.delete(this.proposersOfInvalidBlocks.values().next().value!);
666
- }
667
-
668
712
  this.proposersOfInvalidBlocks.add(proposer.toString());
669
713
 
670
714
  this.emit(WANT_TO_SLASH_EVENT, [
@@ -677,20 +721,148 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
677
721
  ]);
678
722
  }
679
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
+
680
850
  /**
681
851
  * Handle detection of a duplicate proposal (equivocation).
682
852
  * Emits a slash event when a proposer sends multiple proposals for the same position.
683
853
  */
684
854
  private handleDuplicateProposal(info: DuplicateProposalInfo): void {
685
855
  const { slot, proposer, type } = info;
856
+ this.proposalHandler.markProposalEquivocation(slot);
686
857
 
687
- 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}`, {
688
859
  proposer: proposer.toString(),
689
860
  slot,
690
861
  type,
862
+ amount: this.config.slashDuplicateProposalPenalty,
863
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_PROPOSAL),
691
864
  });
692
865
 
693
- // Emit slash event
694
866
  this.emit(WANT_TO_SLASH_EVENT, [
695
867
  {
696
868
  validator: proposer,
@@ -699,6 +871,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
699
871
  epochOrSlot: BigInt(slot),
700
872
  },
701
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
+ ]);
702
881
  }
703
882
 
704
883
  /**
@@ -708,9 +887,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
708
887
  private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
709
888
  const { slot, attester } = info;
710
889
 
711
- 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}`, {
712
891
  attester: attester.toString(),
713
892
  slot,
893
+ amount: this.config.slashDuplicateAttestationPenalty,
894
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_ATTESTATION),
714
895
  });
715
896
 
716
897
  this.emit(WANT_TO_SLASH_EVENT, [
@@ -725,6 +906,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
725
906
 
726
907
  async createBlockProposal(
727
908
  blockHeader: BlockHeader,
909
+ checkpointNumber: CheckpointNumber,
728
910
  indexWithinCheckpoint: IndexWithinCheckpoint,
729
911
  inHash: Fr,
730
912
  archive: Fr,
@@ -751,6 +933,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
751
933
  );
752
934
  const newProposal = await this.validationService.createBlockProposal(
753
935
  blockHeader,
936
+ checkpointNumber,
754
937
  indexWithinCheckpoint,
755
938
  inHash,
756
939
  archive,
@@ -758,7 +941,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
758
941
  proposerAddress,
759
942
  {
760
943
  ...options,
761
- broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
944
+ broadcastInvalidBlockProposal:
945
+ options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal,
762
946
  },
763
947
  );
764
948
  this.lastProposedBlock = newProposal;
@@ -768,8 +952,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
768
952
  async createCheckpointProposal(
769
953
  checkpointHeader: CheckpointHeader,
770
954
  archive: Fr,
955
+ checkpointNumber: CheckpointNumber,
771
956
  feeAssetPriceModifier: bigint,
772
- lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
957
+ lastBlockProposal: BlockProposal | undefined,
773
958
  proposerAddress: EthAddress | undefined,
774
959
  options: CheckpointProposalOptions = {},
775
960
  ): Promise<CheckpointProposal> {
@@ -790,12 +975,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
790
975
  const newProposal = await this.validationService.createCheckpointProposal(
791
976
  checkpointHeader,
792
977
  archive,
978
+ checkpointNumber,
793
979
  feeAssetPriceModifier,
794
- lastBlockInfo,
980
+ lastBlockProposal,
795
981
  proposerAddress,
796
982
  options,
797
983
  );
798
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);
799
992
  return newProposal;
800
993
  }
801
994
 
@@ -807,16 +1000,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
807
1000
  attestationsAndSigners: CommitteeAttestationsAndSigners,
808
1001
  proposer: EthAddress,
809
1002
  slot: SlotNumber,
810
- blockNumber: BlockNumber | CheckpointNumber,
1003
+ checkpointNumber: CheckpointNumber,
811
1004
  ): Promise<Signature> {
812
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
1005
+ return await this.validationService.signAttestationsAndSigners(
1006
+ attestationsAndSigners,
1007
+ proposer,
1008
+ slot,
1009
+ checkpointNumber,
1010
+ );
813
1011
  }
814
1012
 
815
- async collectOwnAttestations(proposal: CheckpointProposal): Promise<CheckpointAttestation[]> {
1013
+ async collectOwnAttestations(
1014
+ proposal: CheckpointProposal,
1015
+ checkpointNumber: CheckpointNumber,
1016
+ ): Promise<CheckpointAttestation[]> {
816
1017
  const slot = proposal.slotNumber;
817
1018
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
818
1019
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
819
- const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
1020
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
820
1021
 
821
1022
  if (!attestations) {
822
1023
  return [];
@@ -835,6 +1036,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
835
1036
  proposal: CheckpointProposal,
836
1037
  required: number,
837
1038
  deadline: Date,
1039
+ checkpointNumber: CheckpointNumber,
838
1040
  ): Promise<CheckpointAttestation[]> {
839
1041
  // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
840
1042
  const slot = proposal.slotNumber;
@@ -847,33 +1049,23 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
847
1049
  throw new AttestationTimeoutError(0, required, slot);
848
1050
  }
849
1051
 
850
- await this.collectOwnAttestations(proposal);
1052
+ await this.collectOwnAttestations(proposal, checkpointNumber);
851
1053
 
852
- const proposalId = proposal.archive.toString();
1054
+ const proposalPayloadHash = proposal.getPayloadHash();
853
1055
  const myAddresses = this.getValidatorAddresses();
854
1056
 
855
1057
  let attestations: CheckpointAttestation[] = [];
856
1058
  while (true) {
857
- // Filter out attestations with a mismatching archive. This should NOT happen since we have verified
858
- // the proposer signature (ie our own) before accepting the attestation into the pool via the p2p client.
859
- const collectedAttestations = (await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalId)).filter(
860
- attestation => {
861
- if (!attestation.archive.equals(proposal.archive)) {
862
- this.log.warn(
863
- `Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`,
864
- { attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() },
865
- );
866
- return false;
867
- }
868
- return true;
869
- },
870
- );
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);
871
1063
 
872
1064
  // Log new attestations we collected
873
1065
  const oldSenders = attestations.map(attestation => attestation.getSender());
874
1066
  for (const collected of collectedAttestations) {
875
1067
  const collectedSender = collected.getSender();
876
- // Skip attestations with invalid signatures
1068
+ // Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
877
1069
  if (!collectedSender) {
878
1070
  this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
879
1071
  continue;