@aztec/validator-client 0.0.1-commit.0c875d939 → 0.0.1-commit.0ec55a70b

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 (52) hide show
  1. package/README.md +41 -2
  2. package/dest/checkpoint_builder.d.ts +19 -6
  3. package/dest/checkpoint_builder.d.ts.map +1 -1
  4. package/dest/checkpoint_builder.js +115 -39
  5. package/dest/config.d.ts +1 -1
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +29 -7
  8. package/dest/duties/validation_service.d.ts +11 -12
  9. package/dest/duties/validation_service.d.ts.map +1 -1
  10. package/dest/duties/validation_service.js +27 -45
  11. package/dest/factory.d.ts +7 -4
  12. package/dest/factory.d.ts.map +1 -1
  13. package/dest/factory.js +11 -5
  14. package/dest/index.d.ts +2 -3
  15. package/dest/index.d.ts.map +1 -1
  16. package/dest/index.js +1 -2
  17. package/dest/key_store/ha_key_store.js +1 -1
  18. package/dest/metrics.d.ts +14 -2
  19. package/dest/metrics.d.ts.map +1 -1
  20. package/dest/metrics.js +24 -0
  21. package/dest/proposal_handler.d.ts +108 -0
  22. package/dest/proposal_handler.d.ts.map +1 -0
  23. package/dest/proposal_handler.js +986 -0
  24. package/dest/validator.d.ts +24 -20
  25. package/dest/validator.d.ts.map +1 -1
  26. package/dest/validator.js +108 -213
  27. package/package.json +19 -19
  28. package/src/checkpoint_builder.ts +135 -39
  29. package/src/config.ts +29 -6
  30. package/src/duties/validation_service.ts +46 -53
  31. package/src/factory.ts +15 -3
  32. package/src/index.ts +1 -2
  33. package/src/key_store/ha_key_store.ts +1 -1
  34. package/src/metrics.ts +37 -1
  35. package/src/proposal_handler.ts +1052 -0
  36. package/src/validator.ts +153 -241
  37. package/dest/block_proposal_handler.d.ts +0 -63
  38. package/dest/block_proposal_handler.d.ts.map +0 -1
  39. package/dest/block_proposal_handler.js +0 -546
  40. package/dest/tx_validator/index.d.ts +0 -3
  41. package/dest/tx_validator/index.d.ts.map +0 -1
  42. package/dest/tx_validator/index.js +0 -2
  43. package/dest/tx_validator/nullifier_cache.d.ts +0 -14
  44. package/dest/tx_validator/nullifier_cache.d.ts.map +0 -1
  45. package/dest/tx_validator/nullifier_cache.js +0 -24
  46. package/dest/tx_validator/tx_validator_factory.d.ts +0 -19
  47. package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
  48. package/dest/tx_validator/tx_validator_factory.js +0 -54
  49. package/src/block_proposal_handler.ts +0 -556
  50. package/src/tx_validator/index.ts +0 -2
  51. package/src/tx_validator/nullifier_cache.ts +0 -30
  52. package/src/tx_validator/tx_validator_factory.ts +0 -154
package/src/validator.ts CHANGED
@@ -1,20 +1,11 @@
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
7
  import type { Signature } from '@aztec/foundation/eth-signature';
16
8
  import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
17
- import { retryUntil } from '@aztec/foundation/retry';
18
9
  import { RunningPromise } from '@aztec/foundation/running-promise';
19
10
  import { sleep } from '@aztec/foundation/sleep';
20
11
  import { DateProvider } from '@aztec/foundation/timer';
@@ -23,16 +14,15 @@ import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } fro
23
14
  import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
24
15
  import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
25
16
  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';
17
+ import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
18
+ import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
28
19
  import type {
29
- CreateCheckpointProposalLastBlockData,
30
20
  ITxProvider,
31
21
  Validator,
32
22
  ValidatorClientFullConfig,
33
23
  WorldStateSynchronizer,
34
24
  } from '@aztec/stdlib/interfaces/server';
35
- import { type L1ToL2MessageSource, accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging';
25
+ import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
36
26
  import {
37
27
  type BlockProposal,
38
28
  type BlockProposalOptions,
@@ -40,24 +30,30 @@ import {
40
30
  CheckpointProposal,
41
31
  type CheckpointProposalCore,
42
32
  type CheckpointProposalOptions,
33
+ type CoordinationSignatureContext,
43
34
  } from '@aztec/stdlib/p2p';
44
35
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
45
- import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
36
+ import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
46
37
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
47
38
  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';
39
+ import {
40
+ createHASigner,
41
+ createLocalSignerWithProtection,
42
+ createSignerFromSharedDb,
43
+ } from '@aztec/validator-ha-signer/factory';
44
+ import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
45
+ import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
50
46
 
51
47
  import { EventEmitter } from 'events';
52
48
  import type { TypedDataDefinition } from 'viem';
53
49
 
54
- import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
55
50
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
56
51
  import { ValidationService } from './duties/validation_service.js';
57
52
  import { HAKeyStore } from './key_store/ha_key_store.js';
58
53
  import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
59
54
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
60
55
  import { ValidatorMetrics } from './metrics.js';
56
+ import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.js';
61
57
 
62
58
  // We maintain a set of proposers who have proposed invalid blocks.
63
59
  // Just cap the set to avoid unbounded growth.
@@ -77,7 +73,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
77
73
  private validationService: ValidationService;
78
74
  private metrics: ValidatorMetrics;
79
75
  private log: Logger;
80
-
81
76
  // Whether it has already registered handlers on the p2p client
82
77
  private hasRegisteredHandlers = false;
83
78
 
@@ -89,6 +84,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
89
84
 
90
85
  private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
91
86
  private epochCacheUpdateLoop: RunningPromise;
87
+ /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
88
+ private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
92
89
 
93
90
  private proposersOfInvalidBlocks: Set<string> = new Set();
94
91
 
@@ -99,13 +96,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
99
96
  private keyStore: ExtendedValidatorKeyStore,
100
97
  private epochCache: EpochCache,
101
98
  private p2pClient: P2P,
102
- private blockProposalHandler: BlockProposalHandler,
99
+ private proposalHandler: ProposalHandler,
103
100
  private blockSource: L2BlockSource,
104
101
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
105
102
  private worldState: WorldStateSynchronizer,
106
103
  private l1ToL2MessageSource: L1ToL2MessageSource,
107
104
  private config: ValidatorClientFullConfig,
108
105
  private blobClient: BlobClientInterface,
106
+ private slashingProtectionSigner: ValidatorHASigner,
109
107
  private dateProvider: DateProvider = new DateProvider(),
110
108
  telemetry: TelemetryClient = getTelemetryClient(),
111
109
  log = createLogger('validator'),
@@ -118,7 +116,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
118
116
  this.tracer = telemetry.getTracer('Validator');
119
117
  this.metrics = new ValidatorMetrics(telemetry);
120
118
 
121
- this.validationService = new ValidationService(keyStore, this.log.createChild('validation-service'));
119
+ this.validationService = new ValidationService(
120
+ keyStore,
121
+ this.getSignatureContext(),
122
+ this.log.createChild('validation-service'),
123
+ );
122
124
 
123
125
  // Refresh epoch cache every second to trigger alert if participation in committee changes
124
126
  this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
@@ -159,6 +161,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
159
161
  this.log.trace(`No committee found for slot`);
160
162
  return;
161
163
  }
164
+ this.metrics.setCurrentEpoch(epoch);
162
165
  if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
163
166
  const me = this.getValidatorAddresses();
164
167
  const committeeSet = new Set(committee.map(v => v.toString()));
@@ -192,12 +195,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
192
195
  blobClient: BlobClientInterface,
193
196
  dateProvider: DateProvider = new DateProvider(),
194
197
  telemetry: TelemetryClient = getTelemetryClient(),
198
+ slashingProtectionDb?: SlashingProtectionDatabase,
195
199
  ) {
196
200
  const metrics = new ValidatorMetrics(telemetry);
197
201
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
198
202
  txsPermitted: !config.disableTransactions,
203
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
204
+ maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint,
205
+ signatureContext: {
206
+ chainId: config.l1ChainId,
207
+ rollupAddress: config.l1Contracts.rollupAddress,
208
+ },
199
209
  });
200
- const blockProposalHandler = new BlockProposalHandler(
210
+ const proposalHandler = new ProposalHandler(
201
211
  checkpointsBuilder,
202
212
  worldState,
203
213
  blockSource,
@@ -206,33 +216,54 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
206
216
  blockProposalValidator,
207
217
  epochCache,
208
218
  config,
219
+ blobClient,
209
220
  metrics,
210
221
  dateProvider,
211
222
  telemetry,
223
+ undefined,
212
224
  );
213
225
 
214
- let validatorKeyStore: ExtendedValidatorKeyStore = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
215
- if (config.haSigningEnabled) {
226
+ const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
227
+ let slashingProtectionSigner: ValidatorHASigner;
228
+ if (slashingProtectionDb) {
229
+ // Shared database mode: use a pre-existing database (e.g. for testing HA setups).
230
+ ({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
231
+ telemetryClient: telemetry,
232
+ dateProvider,
233
+ }));
234
+ } else if (config.haSigningEnabled) {
235
+ // Multi-node HA mode: use PostgreSQL-backed distributed locking.
216
236
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
217
237
  const haConfig = {
218
238
  ...config,
219
239
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
220
240
  };
221
- const { signer } = await createHASigner(haConfig);
222
- validatorKeyStore = new HAKeyStore(validatorKeyStore, signer);
241
+ ({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
242
+ telemetryClient: telemetry,
243
+ dateProvider,
244
+ }));
245
+ } else {
246
+ // Single-node mode: use LMDB-backed local signing protection.
247
+ // This prevents double-signing if the node crashes and restarts mid-proposal.
248
+ ({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
249
+ telemetryClient: telemetry,
250
+ dateProvider,
251
+ }));
223
252
  }
253
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
224
254
 
225
255
  const validator = new ValidatorClient(
226
256
  validatorKeyStore,
227
257
  epochCache,
228
258
  p2pClient,
229
- blockProposalHandler,
259
+ proposalHandler,
230
260
  blockSource,
231
261
  checkpointsBuilder,
232
262
  worldState,
233
263
  l1ToL2MessageSource,
234
264
  config,
235
265
  blobClient,
266
+ slashingProtectionSigner,
236
267
  dateProvider,
237
268
  telemetry,
238
269
  );
@@ -246,14 +277,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
246
277
  .filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
247
278
  }
248
279
 
249
- public getBlockProposalHandler() {
250
- return this.blockProposalHandler;
280
+ public getProposalHandler() {
281
+ return this.proposalHandler;
251
282
  }
252
283
 
253
284
  public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
254
285
  return this.keyStore.signTypedDataWithAddress(addr, msg, context);
255
286
  }
256
287
 
288
+ private getSignatureContext(): CoordinationSignatureContext {
289
+ return {
290
+ chainId: this.config.l1ChainId,
291
+ rollupAddress: this.config.l1Contracts.rollupAddress,
292
+ };
293
+ }
294
+
257
295
  public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
258
296
  return this.keyStore.getCoinbaseAddress(attestor);
259
297
  }
@@ -270,6 +308,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
270
308
  this.config = { ...this.config, ...config };
271
309
  }
272
310
 
311
+ public reloadKeystore(newManager: KeystoreManager): void {
312
+ const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
313
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
314
+ this.validationService = new ValidationService(
315
+ this.keyStore,
316
+ this.getSignatureContext(),
317
+ this.log.createChild('validation-service'),
318
+ );
319
+ }
320
+
273
321
  public async start() {
274
322
  if (this.epochCacheUpdateLoop.isRunning()) {
275
323
  this.log.warn(`Validator client already started`);
@@ -314,7 +362,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
314
362
  checkpoint: CheckpointProposalCore,
315
363
  proposalSender: PeerId,
316
364
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
317
- this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
365
+ this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
318
366
 
319
367
  // Duplicate proposal handler - triggers slashing for equivocation
320
368
  this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
@@ -353,13 +401,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
353
401
  return false;
354
402
  }
355
403
 
356
- // Ignore proposals from ourselves (may happen in HA setups)
404
+ // Log self-proposals from HA peers (same validator key on different nodes)
357
405
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
358
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
406
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
359
407
  proposer: proposer.toString(),
360
408
  slotNumber,
361
409
  });
362
- return false;
363
410
  }
364
411
 
365
412
  // Check if we're in the committee (for metrics purposes)
@@ -375,25 +422,25 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
375
422
 
376
423
  // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
377
424
  // In fisherman mode, we always reexecute to validate proposals.
378
- const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
379
- this.config;
425
+ const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
380
426
  const shouldReexecute =
381
427
  fishermanMode ||
382
- (slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
383
- (partOfCommittee && validatorReexecute) ||
428
+ slashBroadcastedInvalidBlockPenalty > 0n ||
429
+ partOfCommittee ||
384
430
  alwaysReexecuteBlockProposals ||
385
431
  this.blobClient.canUpload();
386
432
 
387
- const validationResult = await this.blockProposalHandler.handleBlockProposal(
433
+ const validationResult = await this.proposalHandler.handleBlockProposal(
388
434
  proposal,
389
435
  proposalSender,
390
436
  !!shouldReexecute && !escapeHatchOpen,
391
437
  );
392
438
 
393
439
  if (!validationResult.isValid) {
394
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
395
-
396
440
  const reason = validationResult.reason || 'unknown';
441
+
442
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
443
+
397
444
  // Classify failure reason: bad proposal vs node issue
398
445
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
399
446
  'invalid_proposal',
@@ -448,68 +495,51 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
448
495
  proposal: CheckpointProposalCore,
449
496
  _proposalSender: PeerId,
450
497
  ): Promise<CheckpointAttestation[] | undefined> {
451
- const slotNumber = proposal.slotNumber;
498
+ const proposalSlotNumber = proposal.slotNumber;
452
499
  const proposer = proposal.getSender();
453
500
 
454
501
  // If escape hatch is open for this slot's epoch, do not attest.
455
- if (await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber)) {
456
- this.log.warn(`Escape hatch open for slot ${slotNumber}, skipping checkpoint attestation handling`);
457
- return undefined;
458
- }
459
-
460
- // Reject proposals with invalid signatures
461
- if (!proposer) {
462
- this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
502
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
503
+ this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
463
504
  return undefined;
464
505
  }
465
506
 
466
507
  // Ignore proposals from ourselves (may happen in HA setups)
467
- if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
468
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
508
+ if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
509
+ this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
469
510
  proposer: proposer.toString(),
470
- slotNumber,
511
+ proposalSlotNumber,
471
512
  });
472
513
  return undefined;
473
514
  }
474
515
 
475
- // Validate fee asset price modifier is within allowed range
476
- if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
477
- this.log.warn(
478
- `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`,
479
- );
480
- return undefined;
481
- }
482
-
483
- // Check that I have any address in current committee before attesting
484
- const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
516
+ // Check that I have any address in the committee where this checkpoint will land before attesting
517
+ const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
485
518
  const partOfCommittee = inCommittee.length > 0;
486
519
 
487
520
  const proposalInfo = {
488
- slotNumber,
521
+ proposalSlotNumber,
489
522
  archive: proposal.archive.toString(),
490
- proposer: proposer.toString(),
491
- txCount: proposal.txHashes.length,
523
+ proposer: proposer?.toString(),
492
524
  };
493
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
525
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
494
526
  ...proposalInfo,
495
- txHashes: proposal.txHashes.map(t => t.toString()),
496
527
  fishermanMode: this.config.fishermanMode || false,
497
528
  });
498
529
 
499
- // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
530
+ // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
531
+ // Uses the cached result from the all-nodes callback if available (avoids double validation).
532
+ let checkpointNumber: CheckpointNumber;
500
533
  if (this.config.skipCheckpointProposalValidation) {
501
- this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
534
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
535
+ checkpointNumber = CheckpointNumber(0);
502
536
  } else {
503
- const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
537
+ const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
504
538
  if (!validationResult.isValid) {
505
539
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
506
540
  return undefined;
507
541
  }
508
- }
509
-
510
- // Upload blobs to filestore if we can (fire and forget)
511
- if (this.blobClient.canUpload()) {
512
- void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
542
+ checkpointNumber = validationResult.checkpointNumber;
513
543
  }
514
544
 
515
545
  // Check that I have any address in current committee before attesting
@@ -520,14 +550,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
520
550
  }
521
551
 
522
552
  // Provided all of the above checks pass, we can attest to the proposal
523
- this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, {
524
- ...proposalInfo,
525
- inCommittee: partOfCommittee,
526
- fishermanMode: this.config.fishermanMode || false,
527
- });
553
+ this.log.info(
554
+ `${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
555
+ {
556
+ ...proposalInfo,
557
+ inCommittee: partOfCommittee,
558
+ fishermanMode: this.config.fishermanMode || false,
559
+ },
560
+ );
528
561
 
529
562
  this.metrics.incSuccessfulAttestations(inCommittee.length);
530
563
 
564
+ // Track epoch participation per attester: count each (attester, epoch) pair at most once
565
+ const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
566
+ for (const attester of inCommittee) {
567
+ const key = attester.toString();
568
+ const lastEpoch = this.lastAttestedEpochByAttester.get(key);
569
+ if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
570
+ this.lastAttestedEpochByAttester.set(key, proposalEpoch);
571
+ this.metrics.incAttestedEpochCount(attester);
572
+ }
573
+ }
574
+
531
575
  // Determine which validators should attest
532
576
  let attestors: EthAddress[];
533
577
  if (partOfCommittee) {
@@ -546,14 +590,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
546
590
 
547
591
  if (this.config.fishermanMode) {
548
592
  // bail out early and don't save attestations to the pool in fisherman mode
549
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
593
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
550
594
  ...proposalInfo,
551
595
  attestors: attestors.map(a => a.toString()),
552
596
  });
553
597
  return undefined;
554
598
  }
555
599
 
556
- return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
600
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
557
601
  }
558
602
 
559
603
  /**
@@ -580,13 +624,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
580
624
  private async createCheckpointAttestationsFromProposal(
581
625
  proposal: CheckpointProposalCore,
582
626
  attestors: EthAddress[] = [],
627
+ checkpointNumber: CheckpointNumber,
583
628
  ): Promise<CheckpointAttestation[] | undefined> {
584
629
  // Equivocation check: must happen right before signing to minimize the race window
585
630
  if (!this.shouldAttestToSlot(proposal.slotNumber)) {
586
631
  return undefined;
587
632
  }
588
633
 
589
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
634
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
590
635
 
591
636
  // Track the proposal we attested to (to prevent equivocation)
592
637
  this.lastAttestedProposal = proposal;
@@ -595,158 +640,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
595
640
  return attestations;
596
641
  }
597
642
 
598
- /**
599
- * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
600
- * @returns Validation result with isValid flag and reason if invalid.
601
- */
602
- private async validateCheckpointProposal(
603
- proposal: CheckpointProposalCore,
604
- proposalInfo: LogData,
605
- ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
606
- const slot = proposal.slotNumber;
607
-
608
- // Timeout block syncing at the start of the next slot
609
- const config = this.checkpointsBuilder.getConfig();
610
- const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
611
- const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
612
-
613
- // Wait for last block to sync by archive
614
- let lastBlockHeader: BlockHeader | undefined;
615
- try {
616
- lastBlockHeader = await retryUntil(
617
- async () => {
618
- await this.blockSource.syncImmediate();
619
- return this.blockSource.getBlockHeaderByArchive(proposal.archive);
620
- },
621
- `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
622
- timeoutSeconds,
623
- 0.5,
624
- );
625
- } catch (err) {
626
- if (err instanceof TimeoutError) {
627
- this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
628
- return { isValid: false, reason: 'last_block_not_found' };
629
- }
630
- this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
631
- return { isValid: false, reason: 'block_fetch_error' };
632
- }
633
-
634
- if (!lastBlockHeader) {
635
- this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
636
- return { isValid: false, reason: 'last_block_not_found' };
637
- }
638
-
639
- // Get all full blocks for the slot and checkpoint
640
- const blocks = await this.blockSource.getBlocksForSlot(slot);
641
- if (blocks.length === 0) {
642
- this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
643
- return { isValid: false, reason: 'no_blocks_for_slot' };
644
- }
645
-
646
- this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
647
- ...proposalInfo,
648
- blockNumbers: blocks.map(b => b.number),
649
- });
650
-
651
- // Get checkpoint constants from first block
652
- const firstBlock = blocks[0];
653
- const constants = this.extractCheckpointConstants(firstBlock);
654
- const checkpointNumber = firstBlock.checkpointNumber;
655
-
656
- // Get L1-to-L2 messages for this checkpoint
657
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
658
-
659
- // Compute the previous checkpoint out hashes for the epoch.
660
- // TODO: There can be a more efficient way to get the previous checkpoint out hashes without having to fetch the
661
- // actual checkpoints and the blocks/txs in them.
662
- const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
663
- const previousCheckpoints = (await this.blockSource.getCheckpointsForEpoch(epoch))
664
- .filter(b => b.number < checkpointNumber)
665
- .sort((a, b) => a.number - b.number);
666
- const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
667
-
668
- // Fork world state at the block before the first block
669
- const parentBlockNumber = BlockNumber(firstBlock.number - 1);
670
- const fork = await this.worldState.fork(parentBlockNumber);
671
-
672
- try {
673
- // Create checkpoint builder with all existing blocks
674
- const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
675
- checkpointNumber,
676
- constants,
677
- proposal.feeAssetPriceModifier,
678
- l1ToL2Messages,
679
- previousCheckpointOutHashes,
680
- fork,
681
- blocks,
682
- this.log.getBindings(),
683
- );
684
-
685
- // Complete the checkpoint to get computed values
686
- const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
687
-
688
- // Compare checkpoint header with proposal
689
- if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
690
- this.log.warn(`Checkpoint header mismatch`, {
691
- ...proposalInfo,
692
- computed: computedCheckpoint.header.toInspect(),
693
- proposal: proposal.checkpointHeader.toInspect(),
694
- });
695
- return { isValid: false, reason: 'checkpoint_header_mismatch' };
696
- }
697
-
698
- // Compare archive root with proposal
699
- if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
700
- this.log.warn(`Archive root mismatch`, {
701
- ...proposalInfo,
702
- computed: computedCheckpoint.archive.root.toString(),
703
- proposal: proposal.archive.toString(),
704
- });
705
- return { isValid: false, reason: 'archive_mismatch' };
706
- }
707
-
708
- // Check that the accumulated epoch out hash matches the value in the proposal.
709
- // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
710
- const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
711
- const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
712
- const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
713
- if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
714
- this.log.warn(`Epoch out hash mismatch`, {
715
- proposalEpochOutHash: proposalEpochOutHash.toString(),
716
- computedEpochOutHash: computedEpochOutHash.toString(),
717
- checkpointOutHash: checkpointOutHash.toString(),
718
- previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
719
- ...proposalInfo,
720
- });
721
- return { isValid: false, reason: 'out_hash_mismatch' };
722
- }
723
-
724
- this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
725
- return { isValid: true };
726
- } finally {
727
- await fork.close();
728
- }
729
- }
730
-
731
- /**
732
- * Extract checkpoint global variables from a block.
733
- */
734
- private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
735
- const gv = block.header.globalVariables;
736
- return {
737
- chainId: gv.chainId,
738
- version: gv.version,
739
- slotNumber: gv.slotNumber,
740
- coinbase: gv.coinbase,
741
- feeRecipient: gv.feeRecipient,
742
- gasFees: gv.gasFees,
743
- };
744
- }
745
-
746
643
  /**
747
644
  * Uploads blobs for a checkpoint to the filestore (fire and forget).
748
645
  */
749
- private async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
646
+ protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
750
647
  try {
751
648
  const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
752
649
  if (!lastBlockHeader) {
@@ -761,7 +658,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
761
658
  }
762
659
 
763
660
  const blobFields = blocks.flatMap(b => b.toBlobFields());
764
- const blobs: Blob[] = getBlobsPerL1Block(blobFields);
661
+ const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
765
662
  await this.blobClient.sendBlobsToFilestore(blobs);
766
663
  this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
767
664
  ...proposalInfo,
@@ -847,6 +744,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
847
744
 
848
745
  async createBlockProposal(
849
746
  blockHeader: BlockHeader,
747
+ checkpointNumber: CheckpointNumber,
850
748
  indexWithinCheckpoint: IndexWithinCheckpoint,
851
749
  inHash: Fr,
852
750
  archive: Fr,
@@ -873,6 +771,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
873
771
  );
874
772
  const newProposal = await this.validationService.createBlockProposal(
875
773
  blockHeader,
774
+ checkpointNumber,
876
775
  indexWithinCheckpoint,
877
776
  inHash,
878
777
  archive,
@@ -890,8 +789,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
890
789
  async createCheckpointProposal(
891
790
  checkpointHeader: CheckpointHeader,
892
791
  archive: Fr,
792
+ checkpointNumber: CheckpointNumber,
893
793
  feeAssetPriceModifier: bigint,
894
- lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
794
+ lastBlockProposal: BlockProposal | undefined,
895
795
  proposerAddress: EthAddress | undefined,
896
796
  options: CheckpointProposalOptions = {},
897
797
  ): Promise<CheckpointProposal> {
@@ -912,8 +812,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
912
812
  const newProposal = await this.validationService.createCheckpointProposal(
913
813
  checkpointHeader,
914
814
  archive,
815
+ checkpointNumber,
915
816
  feeAssetPriceModifier,
916
- lastBlockInfo,
817
+ lastBlockProposal,
917
818
  proposerAddress,
918
819
  options,
919
820
  );
@@ -929,16 +830,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
929
830
  attestationsAndSigners: CommitteeAttestationsAndSigners,
930
831
  proposer: EthAddress,
931
832
  slot: SlotNumber,
932
- blockNumber: BlockNumber | CheckpointNumber,
833
+ checkpointNumber: CheckpointNumber,
933
834
  ): Promise<Signature> {
934
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
835
+ return await this.validationService.signAttestationsAndSigners(
836
+ attestationsAndSigners,
837
+ proposer,
838
+ slot,
839
+ checkpointNumber,
840
+ );
935
841
  }
936
842
 
937
- async collectOwnAttestations(proposal: CheckpointProposal): Promise<CheckpointAttestation[]> {
843
+ async collectOwnAttestations(
844
+ proposal: CheckpointProposal,
845
+ checkpointNumber: CheckpointNumber,
846
+ ): Promise<CheckpointAttestation[]> {
938
847
  const slot = proposal.slotNumber;
939
848
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
940
849
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
941
- const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
850
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
942
851
 
943
852
  if (!attestations) {
944
853
  return [];
@@ -957,6 +866,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
957
866
  proposal: CheckpointProposal,
958
867
  required: number,
959
868
  deadline: Date,
869
+ checkpointNumber: CheckpointNumber,
960
870
  ): Promise<CheckpointAttestation[]> {
961
871
  // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
962
872
  const slot = proposal.slotNumber;
@@ -969,7 +879,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
969
879
  throw new AttestationTimeoutError(0, required, slot);
970
880
  }
971
881
 
972
- await this.collectOwnAttestations(proposal);
882
+ await this.collectOwnAttestations(proposal, checkpointNumber);
973
883
 
974
884
  const proposalId = proposal.archive.toString();
975
885
  const myAddresses = this.getValidatorAddresses();
@@ -982,7 +892,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
982
892
  attestation => {
983
893
  if (!attestation.archive.equals(proposal.archive)) {
984
894
  this.log.warn(
985
- `Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`,
895
+ `Received attestation for slot ${slot} with mismatched archive from ${attestation
896
+ .getSender()
897
+ ?.toString()}`,
986
898
  { attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() },
987
899
  );
988
900
  return false;