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

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 +22 -6
  8. package/dest/duties/validation_service.d.ts +7 -9
  9. package/dest/duties/validation_service.d.ts.map +1 -1
  10. package/dest/duties/validation_service.js +14 -32
  11. package/dest/factory.d.ts +7 -4
  12. package/dest/factory.d.ts.map +1 -1
  13. package/dest/factory.js +6 -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 +974 -0
  24. package/dest/validator.d.ts +23 -20
  25. package/dest/validator.d.ts.map +1 -1
  26. package/dest/validator.js +96 -212
  27. package/package.json +19 -19
  28. package/src/checkpoint_builder.ts +135 -39
  29. package/src/config.ts +22 -6
  30. package/src/duties/validation_service.ts +17 -36
  31. package/src/factory.ts +10 -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 +1042 -0
  36. package/src/validator.ts +128 -239
  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,
@@ -42,22 +32,27 @@ import {
42
32
  type CheckpointProposalOptions,
43
33
  } from '@aztec/stdlib/p2p';
44
34
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
45
- import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
35
+ import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
46
36
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
47
37
  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';
38
+ import {
39
+ createHASigner,
40
+ createLocalSignerWithProtection,
41
+ createSignerFromSharedDb,
42
+ } from '@aztec/validator-ha-signer/factory';
43
+ import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
44
+ import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
50
45
 
51
46
  import { EventEmitter } from 'events';
52
47
  import type { TypedDataDefinition } from 'viem';
53
48
 
54
- import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
55
49
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
56
50
  import { ValidationService } from './duties/validation_service.js';
57
51
  import { HAKeyStore } from './key_store/ha_key_store.js';
58
52
  import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
59
53
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
60
54
  import { ValidatorMetrics } from './metrics.js';
55
+ import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.js';
61
56
 
62
57
  // We maintain a set of proposers who have proposed invalid blocks.
63
58
  // Just cap the set to avoid unbounded growth.
@@ -77,7 +72,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
77
72
  private validationService: ValidationService;
78
73
  private metrics: ValidatorMetrics;
79
74
  private log: Logger;
80
-
81
75
  // Whether it has already registered handlers on the p2p client
82
76
  private hasRegisteredHandlers = false;
83
77
 
@@ -89,6 +83,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
89
83
 
90
84
  private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
91
85
  private epochCacheUpdateLoop: RunningPromise;
86
+ /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
87
+ private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
92
88
 
93
89
  private proposersOfInvalidBlocks: Set<string> = new Set();
94
90
 
@@ -99,13 +95,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
99
95
  private keyStore: ExtendedValidatorKeyStore,
100
96
  private epochCache: EpochCache,
101
97
  private p2pClient: P2P,
102
- private blockProposalHandler: BlockProposalHandler,
98
+ private proposalHandler: ProposalHandler,
103
99
  private blockSource: L2BlockSource,
104
100
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
105
101
  private worldState: WorldStateSynchronizer,
106
102
  private l1ToL2MessageSource: L1ToL2MessageSource,
107
103
  private config: ValidatorClientFullConfig,
108
104
  private blobClient: BlobClientInterface,
105
+ private slashingProtectionSigner: ValidatorHASigner,
109
106
  private dateProvider: DateProvider = new DateProvider(),
110
107
  telemetry: TelemetryClient = getTelemetryClient(),
111
108
  log = createLogger('validator'),
@@ -159,6 +156,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
159
156
  this.log.trace(`No committee found for slot`);
160
157
  return;
161
158
  }
159
+ this.metrics.setCurrentEpoch(epoch);
162
160
  if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
163
161
  const me = this.getValidatorAddresses();
164
162
  const committeeSet = new Set(committee.map(v => v.toString()));
@@ -192,12 +190,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
192
190
  blobClient: BlobClientInterface,
193
191
  dateProvider: DateProvider = new DateProvider(),
194
192
  telemetry: TelemetryClient = getTelemetryClient(),
193
+ slashingProtectionDb?: SlashingProtectionDatabase,
195
194
  ) {
196
195
  const metrics = new ValidatorMetrics(telemetry);
197
196
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
198
197
  txsPermitted: !config.disableTransactions,
198
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
199
199
  });
200
- const blockProposalHandler = new BlockProposalHandler(
200
+ const proposalHandler = new ProposalHandler(
201
201
  checkpointsBuilder,
202
202
  worldState,
203
203
  blockSource,
@@ -206,33 +206,54 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
206
206
  blockProposalValidator,
207
207
  epochCache,
208
208
  config,
209
+ blobClient,
209
210
  metrics,
210
211
  dateProvider,
211
212
  telemetry,
213
+ undefined,
212
214
  );
213
215
 
214
- let validatorKeyStore: ExtendedValidatorKeyStore = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
215
- if (config.haSigningEnabled) {
216
+ const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
217
+ let slashingProtectionSigner: ValidatorHASigner;
218
+ if (slashingProtectionDb) {
219
+ // Shared database mode: use a pre-existing database (e.g. for testing HA setups).
220
+ ({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
221
+ telemetryClient: telemetry,
222
+ dateProvider,
223
+ }));
224
+ } else if (config.haSigningEnabled) {
225
+ // Multi-node HA mode: use PostgreSQL-backed distributed locking.
216
226
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
217
227
  const haConfig = {
218
228
  ...config,
219
229
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
220
230
  };
221
- const { signer } = await createHASigner(haConfig);
222
- validatorKeyStore = new HAKeyStore(validatorKeyStore, signer);
231
+ ({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
232
+ telemetryClient: telemetry,
233
+ dateProvider,
234
+ }));
235
+ } else {
236
+ // Single-node mode: use LMDB-backed local signing protection.
237
+ // This prevents double-signing if the node crashes and restarts mid-proposal.
238
+ ({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
239
+ telemetryClient: telemetry,
240
+ dateProvider,
241
+ }));
223
242
  }
243
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
224
244
 
225
245
  const validator = new ValidatorClient(
226
246
  validatorKeyStore,
227
247
  epochCache,
228
248
  p2pClient,
229
- blockProposalHandler,
249
+ proposalHandler,
230
250
  blockSource,
231
251
  checkpointsBuilder,
232
252
  worldState,
233
253
  l1ToL2MessageSource,
234
254
  config,
235
255
  blobClient,
256
+ slashingProtectionSigner,
236
257
  dateProvider,
237
258
  telemetry,
238
259
  );
@@ -246,8 +267,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
246
267
  .filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
247
268
  }
248
269
 
249
- public getBlockProposalHandler() {
250
- return this.blockProposalHandler;
270
+ public getProposalHandler() {
271
+ return this.proposalHandler;
251
272
  }
252
273
 
253
274
  public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
@@ -270,6 +291,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
270
291
  this.config = { ...this.config, ...config };
271
292
  }
272
293
 
294
+ public reloadKeystore(newManager: KeystoreManager): void {
295
+ const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
296
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
297
+ this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
298
+ }
299
+
273
300
  public async start() {
274
301
  if (this.epochCacheUpdateLoop.isRunning()) {
275
302
  this.log.warn(`Validator client already started`);
@@ -314,7 +341,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
314
341
  checkpoint: CheckpointProposalCore,
315
342
  proposalSender: PeerId,
316
343
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
317
- this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
344
+ this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
318
345
 
319
346
  // Duplicate proposal handler - triggers slashing for equivocation
320
347
  this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
@@ -353,13 +380,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
353
380
  return false;
354
381
  }
355
382
 
356
- // Ignore proposals from ourselves (may happen in HA setups)
383
+ // Log self-proposals from HA peers (same validator key on different nodes)
357
384
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
358
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
385
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
359
386
  proposer: proposer.toString(),
360
387
  slotNumber,
361
388
  });
362
- return false;
363
389
  }
364
390
 
365
391
  // Check if we're in the committee (for metrics purposes)
@@ -375,25 +401,25 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
375
401
 
376
402
  // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
377
403
  // In fisherman mode, we always reexecute to validate proposals.
378
- const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
379
- this.config;
404
+ const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
380
405
  const shouldReexecute =
381
406
  fishermanMode ||
382
- (slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
383
- (partOfCommittee && validatorReexecute) ||
407
+ slashBroadcastedInvalidBlockPenalty > 0n ||
408
+ partOfCommittee ||
384
409
  alwaysReexecuteBlockProposals ||
385
410
  this.blobClient.canUpload();
386
411
 
387
- const validationResult = await this.blockProposalHandler.handleBlockProposal(
412
+ const validationResult = await this.proposalHandler.handleBlockProposal(
388
413
  proposal,
389
414
  proposalSender,
390
415
  !!shouldReexecute && !escapeHatchOpen,
391
416
  );
392
417
 
393
418
  if (!validationResult.isValid) {
394
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
395
-
396
419
  const reason = validationResult.reason || 'unknown';
420
+
421
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
422
+
397
423
  // Classify failure reason: bad proposal vs node issue
398
424
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
399
425
  'invalid_proposal',
@@ -448,68 +474,51 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
448
474
  proposal: CheckpointProposalCore,
449
475
  _proposalSender: PeerId,
450
476
  ): Promise<CheckpointAttestation[] | undefined> {
451
- const slotNumber = proposal.slotNumber;
477
+ const proposalSlotNumber = proposal.slotNumber;
452
478
  const proposer = proposal.getSender();
453
479
 
454
480
  // 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}`);
481
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
482
+ this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
463
483
  return undefined;
464
484
  }
465
485
 
466
486
  // 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}`, {
487
+ if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
488
+ this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
469
489
  proposer: proposer.toString(),
470
- slotNumber,
490
+ proposalSlotNumber,
471
491
  });
472
492
  return undefined;
473
493
  }
474
494
 
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());
495
+ // Check that I have any address in the committee where this checkpoint will land before attesting
496
+ const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
485
497
  const partOfCommittee = inCommittee.length > 0;
486
498
 
487
499
  const proposalInfo = {
488
- slotNumber,
500
+ proposalSlotNumber,
489
501
  archive: proposal.archive.toString(),
490
- proposer: proposer.toString(),
491
- txCount: proposal.txHashes.length,
502
+ proposer: proposer?.toString(),
492
503
  };
493
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
504
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
494
505
  ...proposalInfo,
495
- txHashes: proposal.txHashes.map(t => t.toString()),
496
506
  fishermanMode: this.config.fishermanMode || false,
497
507
  });
498
508
 
499
- // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
509
+ // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
510
+ // Uses the cached result from the all-nodes callback if available (avoids double validation).
511
+ let checkpointNumber: CheckpointNumber;
500
512
  if (this.config.skipCheckpointProposalValidation) {
501
- this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
513
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
514
+ checkpointNumber = CheckpointNumber(0);
502
515
  } else {
503
- const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
516
+ const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
504
517
  if (!validationResult.isValid) {
505
518
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
506
519
  return undefined;
507
520
  }
508
- }
509
-
510
- // Upload blobs to filestore if we can (fire and forget)
511
- if (this.blobClient.canUpload()) {
512
- void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
521
+ checkpointNumber = validationResult.checkpointNumber;
513
522
  }
514
523
 
515
524
  // Check that I have any address in current committee before attesting
@@ -520,14 +529,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
520
529
  }
521
530
 
522
531
  // 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
- });
532
+ this.log.info(
533
+ `${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
534
+ {
535
+ ...proposalInfo,
536
+ inCommittee: partOfCommittee,
537
+ fishermanMode: this.config.fishermanMode || false,
538
+ },
539
+ );
528
540
 
529
541
  this.metrics.incSuccessfulAttestations(inCommittee.length);
530
542
 
543
+ // Track epoch participation per attester: count each (attester, epoch) pair at most once
544
+ const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
545
+ for (const attester of inCommittee) {
546
+ const key = attester.toString();
547
+ const lastEpoch = this.lastAttestedEpochByAttester.get(key);
548
+ if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
549
+ this.lastAttestedEpochByAttester.set(key, proposalEpoch);
550
+ this.metrics.incAttestedEpochCount(attester);
551
+ }
552
+ }
553
+
531
554
  // Determine which validators should attest
532
555
  let attestors: EthAddress[];
533
556
  if (partOfCommittee) {
@@ -546,14 +569,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
546
569
 
547
570
  if (this.config.fishermanMode) {
548
571
  // bail out early and don't save attestations to the pool in fisherman mode
549
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
572
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
550
573
  ...proposalInfo,
551
574
  attestors: attestors.map(a => a.toString()),
552
575
  });
553
576
  return undefined;
554
577
  }
555
578
 
556
- return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
579
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
557
580
  }
558
581
 
559
582
  /**
@@ -580,13 +603,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
580
603
  private async createCheckpointAttestationsFromProposal(
581
604
  proposal: CheckpointProposalCore,
582
605
  attestors: EthAddress[] = [],
606
+ checkpointNumber: CheckpointNumber,
583
607
  ): Promise<CheckpointAttestation[] | undefined> {
584
608
  // Equivocation check: must happen right before signing to minimize the race window
585
609
  if (!this.shouldAttestToSlot(proposal.slotNumber)) {
586
610
  return undefined;
587
611
  }
588
612
 
589
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
613
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
590
614
 
591
615
  // Track the proposal we attested to (to prevent equivocation)
592
616
  this.lastAttestedProposal = proposal;
@@ -595,158 +619,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
595
619
  return attestations;
596
620
  }
597
621
 
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
622
  /**
747
623
  * Uploads blobs for a checkpoint to the filestore (fire and forget).
748
624
  */
749
- private async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
625
+ protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
750
626
  try {
751
627
  const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
752
628
  if (!lastBlockHeader) {
@@ -761,7 +637,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
761
637
  }
762
638
 
763
639
  const blobFields = blocks.flatMap(b => b.toBlobFields());
764
- const blobs: Blob[] = getBlobsPerL1Block(blobFields);
640
+ const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
765
641
  await this.blobClient.sendBlobsToFilestore(blobs);
766
642
  this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
767
643
  ...proposalInfo,
@@ -847,6 +723,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
847
723
 
848
724
  async createBlockProposal(
849
725
  blockHeader: BlockHeader,
726
+ checkpointNumber: CheckpointNumber,
850
727
  indexWithinCheckpoint: IndexWithinCheckpoint,
851
728
  inHash: Fr,
852
729
  archive: Fr,
@@ -873,6 +750,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
873
750
  );
874
751
  const newProposal = await this.validationService.createBlockProposal(
875
752
  blockHeader,
753
+ checkpointNumber,
876
754
  indexWithinCheckpoint,
877
755
  inHash,
878
756
  archive,
@@ -890,8 +768,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
890
768
  async createCheckpointProposal(
891
769
  checkpointHeader: CheckpointHeader,
892
770
  archive: Fr,
771
+ checkpointNumber: CheckpointNumber,
893
772
  feeAssetPriceModifier: bigint,
894
- lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
773
+ lastBlockProposal: BlockProposal | undefined,
895
774
  proposerAddress: EthAddress | undefined,
896
775
  options: CheckpointProposalOptions = {},
897
776
  ): Promise<CheckpointProposal> {
@@ -912,8 +791,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
912
791
  const newProposal = await this.validationService.createCheckpointProposal(
913
792
  checkpointHeader,
914
793
  archive,
794
+ checkpointNumber,
915
795
  feeAssetPriceModifier,
916
- lastBlockInfo,
796
+ lastBlockProposal,
917
797
  proposerAddress,
918
798
  options,
919
799
  );
@@ -929,16 +809,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
929
809
  attestationsAndSigners: CommitteeAttestationsAndSigners,
930
810
  proposer: EthAddress,
931
811
  slot: SlotNumber,
932
- blockNumber: BlockNumber | CheckpointNumber,
812
+ checkpointNumber: CheckpointNumber,
933
813
  ): Promise<Signature> {
934
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
814
+ return await this.validationService.signAttestationsAndSigners(
815
+ attestationsAndSigners,
816
+ proposer,
817
+ slot,
818
+ checkpointNumber,
819
+ );
935
820
  }
936
821
 
937
- async collectOwnAttestations(proposal: CheckpointProposal): Promise<CheckpointAttestation[]> {
822
+ async collectOwnAttestations(
823
+ proposal: CheckpointProposal,
824
+ checkpointNumber: CheckpointNumber,
825
+ ): Promise<CheckpointAttestation[]> {
938
826
  const slot = proposal.slotNumber;
939
827
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
940
828
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
941
- const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
829
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
942
830
 
943
831
  if (!attestations) {
944
832
  return [];
@@ -957,6 +845,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
957
845
  proposal: CheckpointProposal,
958
846
  required: number,
959
847
  deadline: Date,
848
+ checkpointNumber: CheckpointNumber,
960
849
  ): Promise<CheckpointAttestation[]> {
961
850
  // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
962
851
  const slot = proposal.slotNumber;
@@ -969,7 +858,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
969
858
  throw new AttestationTimeoutError(0, required, slot);
970
859
  }
971
860
 
972
- await this.collectOwnAttestations(proposal);
861
+ await this.collectOwnAttestations(proposal, checkpointNumber);
973
862
 
974
863
  const proposalId = proposal.archive.toString();
975
864
  const myAddresses = this.getValidatorAddresses();