@aztec/validator-client 0.0.1-commit.343b43af6 → 0.0.1-commit.350e0a4d

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