@aztec/validator-client 0.0.1-commit.934299a21 → 0.0.1-commit.949a33fd8

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 (40) hide show
  1. package/README.md +41 -2
  2. package/dest/checkpoint_builder.d.ts +14 -4
  3. package/dest/checkpoint_builder.d.ts.map +1 -1
  4. package/dest/checkpoint_builder.js +101 -30
  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 +10 -5
  14. package/dest/index.d.ts +2 -2
  15. package/dest/index.d.ts.map +1 -1
  16. package/dest/index.js +1 -1
  17. package/dest/key_store/ha_key_store.js +1 -1
  18. package/dest/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 +19 -21
  25. package/dest/validator.d.ts.map +1 -1
  26. package/dest/validator.js +99 -232
  27. package/package.json +19 -19
  28. package/src/checkpoint_builder.ts +124 -35
  29. package/src/config.ts +29 -6
  30. package/src/duties/validation_service.ts +46 -53
  31. package/src/factory.ts +14 -3
  32. package/src/index.ts +1 -1
  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 +144 -264
  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 -532
  40. package/src/block_proposal_handler.ts +0 -535
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,25 +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';
50
45
  import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
51
46
 
52
47
  import { EventEmitter } from 'events';
53
48
  import type { TypedDataDefinition } from 'viem';
54
49
 
55
- import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
56
50
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
57
51
  import { ValidationService } from './duties/validation_service.js';
58
52
  import { HAKeyStore } from './key_store/ha_key_store.js';
59
53
  import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
60
54
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
61
55
  import { ValidatorMetrics } from './metrics.js';
56
+ import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.js';
62
57
 
63
58
  // We maintain a set of proposers who have proposed invalid blocks.
64
59
  // Just cap the set to avoid unbounded growth.
@@ -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,14 +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,
109
- private haSigner: ValidatorHASigner | undefined,
106
+ private slashingProtectionSigner: ValidatorHASigner,
110
107
  private dateProvider: DateProvider = new DateProvider(),
111
108
  telemetry: TelemetryClient = getTelemetryClient(),
112
109
  log = createLogger('validator'),
@@ -119,7 +116,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
119
116
  this.tracer = telemetry.getTracer('Validator');
120
117
  this.metrics = new ValidatorMetrics(telemetry);
121
118
 
122
- 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
+ );
123
124
 
124
125
  // Refresh epoch cache every second to trigger alert if participation in committee changes
125
126
  this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
@@ -160,6 +161,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
160
161
  this.log.trace(`No committee found for slot`);
161
162
  return;
162
163
  }
164
+ this.metrics.setCurrentEpoch(epoch);
163
165
  if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
164
166
  const me = this.getValidatorAddresses();
165
167
  const committeeSet = new Set(committee.map(v => v.toString()));
@@ -193,12 +195,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
193
195
  blobClient: BlobClientInterface,
194
196
  dateProvider: DateProvider = new DateProvider(),
195
197
  telemetry: TelemetryClient = getTelemetryClient(),
198
+ slashingProtectionDb?: SlashingProtectionDatabase,
196
199
  ) {
197
200
  const metrics = new ValidatorMetrics(telemetry);
198
201
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
199
202
  txsPermitted: !config.disableTransactions,
203
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
204
+ signatureContext: {
205
+ chainId: config.l1ChainId,
206
+ rollupAddress: config.l1Contracts.rollupAddress,
207
+ },
200
208
  });
201
- const blockProposalHandler = new BlockProposalHandler(
209
+ const proposalHandler = new ProposalHandler(
202
210
  checkpointsBuilder,
203
211
  worldState,
204
212
  blockSource,
@@ -207,37 +215,54 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
207
215
  blockProposalValidator,
208
216
  epochCache,
209
217
  config,
218
+ blobClient,
210
219
  metrics,
211
220
  dateProvider,
212
221
  telemetry,
222
+ undefined,
213
223
  );
214
224
 
215
225
  const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
216
- let validatorKeyStore: ExtendedValidatorKeyStore = nodeKeystoreAdapter;
217
- let haSigner: ValidatorHASigner | undefined;
218
- if (config.haSigningEnabled) {
226
+ let slashingProtectionSigner: ValidatorHASigner;
227
+ if (slashingProtectionDb) {
228
+ // Shared database mode: use a pre-existing database (e.g. for testing HA setups).
229
+ ({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
230
+ telemetryClient: telemetry,
231
+ dateProvider,
232
+ }));
233
+ } else if (config.haSigningEnabled) {
234
+ // Multi-node HA mode: use PostgreSQL-backed distributed locking.
219
235
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
220
236
  const haConfig = {
221
237
  ...config,
222
238
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
223
239
  };
224
- const { signer } = await createHASigner(haConfig, { telemetryClient: telemetry, dateProvider });
225
- haSigner = signer;
226
- validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
240
+ ({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
241
+ telemetryClient: telemetry,
242
+ dateProvider,
243
+ }));
244
+ } else {
245
+ // Single-node mode: use LMDB-backed local signing protection.
246
+ // This prevents double-signing if the node crashes and restarts mid-proposal.
247
+ ({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
248
+ telemetryClient: telemetry,
249
+ dateProvider,
250
+ }));
227
251
  }
252
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
228
253
 
229
254
  const validator = new ValidatorClient(
230
255
  validatorKeyStore,
231
256
  epochCache,
232
257
  p2pClient,
233
- blockProposalHandler,
258
+ proposalHandler,
234
259
  blockSource,
235
260
  checkpointsBuilder,
236
261
  worldState,
237
262
  l1ToL2MessageSource,
238
263
  config,
239
264
  blobClient,
240
- haSigner,
265
+ slashingProtectionSigner,
241
266
  dateProvider,
242
267
  telemetry,
243
268
  );
@@ -251,14 +276,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
251
276
  .filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
252
277
  }
253
278
 
254
- public getBlockProposalHandler() {
255
- return this.blockProposalHandler;
279
+ public getProposalHandler() {
280
+ return this.proposalHandler;
256
281
  }
257
282
 
258
283
  public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
259
284
  return this.keyStore.signTypedDataWithAddress(addr, msg, context);
260
285
  }
261
286
 
287
+ private getSignatureContext(): CoordinationSignatureContext {
288
+ return {
289
+ chainId: this.config.l1ChainId,
290
+ rollupAddress: this.config.l1Contracts.rollupAddress,
291
+ };
292
+ }
293
+
262
294
  public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
263
295
  return this.keyStore.getCoinbaseAddress(attestor);
264
296
  }
@@ -276,25 +308,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
276
308
  }
277
309
 
278
310
  public reloadKeystore(newManager: KeystoreManager): void {
279
- if (this.config.haSigningEnabled && !this.haSigner) {
280
- this.log.warn(
281
- 'HA signing is enabled in config but was not initialized at startup. ' +
282
- 'Restart the node to enable HA signing.',
283
- );
284
- } else if (!this.config.haSigningEnabled && this.haSigner) {
285
- this.log.warn(
286
- 'HA signing was disabled via config update but the HA signer is still active. ' +
287
- 'Restart the node to fully disable HA signing.',
288
- );
289
- }
290
-
291
311
  const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
292
- if (this.haSigner) {
293
- this.keyStore = new HAKeyStore(newAdapter, this.haSigner);
294
- } else {
295
- this.keyStore = newAdapter;
296
- }
297
- this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
312
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
313
+ this.validationService = new ValidationService(
314
+ this.keyStore,
315
+ this.getSignatureContext(),
316
+ this.log.createChild('validation-service'),
317
+ );
298
318
  }
299
319
 
300
320
  public async start() {
@@ -341,7 +361,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
341
361
  checkpoint: CheckpointProposalCore,
342
362
  proposalSender: PeerId,
343
363
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
344
- this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
364
+ this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
345
365
 
346
366
  // Duplicate proposal handler - triggers slashing for equivocation
347
367
  this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
@@ -380,13 +400,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
380
400
  return false;
381
401
  }
382
402
 
383
- // Ignore proposals from ourselves (may happen in HA setups)
403
+ // Log self-proposals from HA peers (same validator key on different nodes)
384
404
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
385
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
405
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
386
406
  proposer: proposer.toString(),
387
407
  slotNumber,
388
408
  });
389
- return false;
390
409
  }
391
410
 
392
411
  // Check if we're in the committee (for metrics purposes)
@@ -402,25 +421,25 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
402
421
 
403
422
  // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
404
423
  // In fisherman mode, we always reexecute to validate proposals.
405
- const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
406
- this.config;
424
+ const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
407
425
  const shouldReexecute =
408
426
  fishermanMode ||
409
- (slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
410
- (partOfCommittee && validatorReexecute) ||
427
+ slashBroadcastedInvalidBlockPenalty > 0n ||
428
+ partOfCommittee ||
411
429
  alwaysReexecuteBlockProposals ||
412
430
  this.blobClient.canUpload();
413
431
 
414
- const validationResult = await this.blockProposalHandler.handleBlockProposal(
432
+ const validationResult = await this.proposalHandler.handleBlockProposal(
415
433
  proposal,
416
434
  proposalSender,
417
435
  !!shouldReexecute && !escapeHatchOpen,
418
436
  );
419
437
 
420
438
  if (!validationResult.isValid) {
421
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
422
-
423
439
  const reason = validationResult.reason || 'unknown';
440
+
441
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
442
+
424
443
  // Classify failure reason: bad proposal vs node issue
425
444
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
426
445
  'invalid_proposal',
@@ -475,68 +494,51 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
475
494
  proposal: CheckpointProposalCore,
476
495
  _proposalSender: PeerId,
477
496
  ): Promise<CheckpointAttestation[] | undefined> {
478
- const slotNumber = proposal.slotNumber;
497
+ const proposalSlotNumber = proposal.slotNumber;
479
498
  const proposer = proposal.getSender();
480
499
 
481
500
  // If escape hatch is open for this slot's epoch, do not attest.
482
- if (await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber)) {
483
- this.log.warn(`Escape hatch open for slot ${slotNumber}, skipping checkpoint attestation handling`);
484
- return undefined;
485
- }
486
-
487
- // Reject proposals with invalid signatures
488
- if (!proposer) {
489
- this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
501
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
502
+ this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
490
503
  return undefined;
491
504
  }
492
505
 
493
506
  // Ignore proposals from ourselves (may happen in HA setups)
494
- if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
495
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
507
+ if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
508
+ this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
496
509
  proposer: proposer.toString(),
497
- slotNumber,
510
+ proposalSlotNumber,
498
511
  });
499
512
  return undefined;
500
513
  }
501
514
 
502
- // Validate fee asset price modifier is within allowed range
503
- if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
504
- this.log.warn(
505
- `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`,
506
- );
507
- return undefined;
508
- }
509
-
510
- // Check that I have any address in current committee before attesting
511
- const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
515
+ // Check that I have any address in the committee where this checkpoint will land before attesting
516
+ const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
512
517
  const partOfCommittee = inCommittee.length > 0;
513
518
 
514
519
  const proposalInfo = {
515
- slotNumber,
520
+ proposalSlotNumber,
516
521
  archive: proposal.archive.toString(),
517
- proposer: proposer.toString(),
518
- txCount: proposal.txHashes.length,
522
+ proposer: proposer?.toString(),
519
523
  };
520
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
524
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
521
525
  ...proposalInfo,
522
- txHashes: proposal.txHashes.map(t => t.toString()),
523
526
  fishermanMode: this.config.fishermanMode || false,
524
527
  });
525
528
 
526
- // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
529
+ // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
530
+ // Uses the cached result from the all-nodes callback if available (avoids double validation).
531
+ let checkpointNumber: CheckpointNumber;
527
532
  if (this.config.skipCheckpointProposalValidation) {
528
- this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
533
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
534
+ checkpointNumber = CheckpointNumber(0);
529
535
  } else {
530
- const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
536
+ const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
531
537
  if (!validationResult.isValid) {
532
538
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
533
539
  return undefined;
534
540
  }
535
- }
536
-
537
- // Upload blobs to filestore if we can (fire and forget)
538
- if (this.blobClient.canUpload()) {
539
- void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
541
+ checkpointNumber = validationResult.checkpointNumber;
540
542
  }
541
543
 
542
544
  // Check that I have any address in current committee before attesting
@@ -547,14 +549,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
547
549
  }
548
550
 
549
551
  // Provided all of the above checks pass, we can attest to the proposal
550
- this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, {
551
- ...proposalInfo,
552
- inCommittee: partOfCommittee,
553
- fishermanMode: this.config.fishermanMode || false,
554
- });
552
+ this.log.info(
553
+ `${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
554
+ {
555
+ ...proposalInfo,
556
+ inCommittee: partOfCommittee,
557
+ fishermanMode: this.config.fishermanMode || false,
558
+ },
559
+ );
555
560
 
556
561
  this.metrics.incSuccessfulAttestations(inCommittee.length);
557
562
 
563
+ // Track epoch participation per attester: count each (attester, epoch) pair at most once
564
+ const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
565
+ for (const attester of inCommittee) {
566
+ const key = attester.toString();
567
+ const lastEpoch = this.lastAttestedEpochByAttester.get(key);
568
+ if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
569
+ this.lastAttestedEpochByAttester.set(key, proposalEpoch);
570
+ this.metrics.incAttestedEpochCount(attester);
571
+ }
572
+ }
573
+
558
574
  // Determine which validators should attest
559
575
  let attestors: EthAddress[];
560
576
  if (partOfCommittee) {
@@ -573,14 +589,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
573
589
 
574
590
  if (this.config.fishermanMode) {
575
591
  // bail out early and don't save attestations to the pool in fisherman mode
576
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
592
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
577
593
  ...proposalInfo,
578
594
  attestors: attestors.map(a => a.toString()),
579
595
  });
580
596
  return undefined;
581
597
  }
582
598
 
583
- return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
599
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
584
600
  }
585
601
 
586
602
  /**
@@ -607,13 +623,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
607
623
  private async createCheckpointAttestationsFromProposal(
608
624
  proposal: CheckpointProposalCore,
609
625
  attestors: EthAddress[] = [],
626
+ checkpointNumber: CheckpointNumber,
610
627
  ): Promise<CheckpointAttestation[] | undefined> {
611
628
  // Equivocation check: must happen right before signing to minimize the race window
612
629
  if (!this.shouldAttestToSlot(proposal.slotNumber)) {
613
630
  return undefined;
614
631
  }
615
632
 
616
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
633
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
617
634
 
618
635
  // Track the proposal we attested to (to prevent equivocation)
619
636
  this.lastAttestedProposal = proposal;
@@ -622,158 +639,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
622
639
  return attestations;
623
640
  }
624
641
 
625
- /**
626
- * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
627
- * @returns Validation result with isValid flag and reason if invalid.
628
- */
629
- private async validateCheckpointProposal(
630
- proposal: CheckpointProposalCore,
631
- proposalInfo: LogData,
632
- ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
633
- const slot = proposal.slotNumber;
634
-
635
- // Timeout block syncing at the start of the next slot
636
- const config = this.checkpointsBuilder.getConfig();
637
- const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
638
- const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
639
-
640
- // Wait for last block to sync by archive
641
- let lastBlockHeader: BlockHeader | undefined;
642
- try {
643
- lastBlockHeader = await retryUntil(
644
- async () => {
645
- await this.blockSource.syncImmediate();
646
- return this.blockSource.getBlockHeaderByArchive(proposal.archive);
647
- },
648
- `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
649
- timeoutSeconds,
650
- 0.5,
651
- );
652
- } catch (err) {
653
- if (err instanceof TimeoutError) {
654
- this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
655
- return { isValid: false, reason: 'last_block_not_found' };
656
- }
657
- this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
658
- return { isValid: false, reason: 'block_fetch_error' };
659
- }
660
-
661
- if (!lastBlockHeader) {
662
- this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
663
- return { isValid: false, reason: 'last_block_not_found' };
664
- }
665
-
666
- // Get all full blocks for the slot and checkpoint
667
- const blocks = await this.blockSource.getBlocksForSlot(slot);
668
- if (blocks.length === 0) {
669
- this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
670
- return { isValid: false, reason: 'no_blocks_for_slot' };
671
- }
672
-
673
- // Ensure the last block for this slot matches the archive in the checkpoint proposal
674
- if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
675
- this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
676
- return { isValid: false, reason: 'last_block_archive_mismatch' };
677
- }
678
-
679
- this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
680
- ...proposalInfo,
681
- blockNumbers: blocks.map(b => b.number),
682
- });
683
-
684
- // Get checkpoint constants from first block
685
- const firstBlock = blocks[0];
686
- const constants = this.extractCheckpointConstants(firstBlock);
687
- const checkpointNumber = firstBlock.checkpointNumber;
688
-
689
- // Get L1-to-L2 messages for this checkpoint
690
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
691
-
692
- // Collect the out hashes of all the checkpoints before this one in the same epoch
693
- const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
694
- const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
695
- .filter(c => c.checkpointNumber < checkpointNumber)
696
- .map(c => c.checkpointOutHash);
697
-
698
- // Fork world state at the block before the first block
699
- const parentBlockNumber = BlockNumber(firstBlock.number - 1);
700
- const fork = await this.worldState.fork(parentBlockNumber);
701
-
702
- try {
703
- // Create checkpoint builder with all existing blocks
704
- const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
705
- checkpointNumber,
706
- constants,
707
- proposal.feeAssetPriceModifier,
708
- l1ToL2Messages,
709
- previousCheckpointOutHashes,
710
- fork,
711
- blocks,
712
- this.log.getBindings(),
713
- );
714
-
715
- // Complete the checkpoint to get computed values
716
- const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
717
-
718
- // Compare checkpoint header with proposal
719
- if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
720
- this.log.warn(`Checkpoint header mismatch`, {
721
- ...proposalInfo,
722
- computed: computedCheckpoint.header.toInspect(),
723
- proposal: proposal.checkpointHeader.toInspect(),
724
- });
725
- return { isValid: false, reason: 'checkpoint_header_mismatch' };
726
- }
727
-
728
- // Compare archive root with proposal
729
- if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
730
- this.log.warn(`Archive root mismatch`, {
731
- ...proposalInfo,
732
- computed: computedCheckpoint.archive.root.toString(),
733
- proposal: proposal.archive.toString(),
734
- });
735
- return { isValid: false, reason: 'archive_mismatch' };
736
- }
737
-
738
- // Check that the accumulated epoch out hash matches the value in the proposal.
739
- // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
740
- const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
741
- const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
742
- const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
743
- if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
744
- this.log.warn(`Epoch out hash mismatch`, {
745
- proposalEpochOutHash: proposalEpochOutHash.toString(),
746
- computedEpochOutHash: computedEpochOutHash.toString(),
747
- checkpointOutHash: checkpointOutHash.toString(),
748
- previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
749
- ...proposalInfo,
750
- });
751
- return { isValid: false, reason: 'out_hash_mismatch' };
752
- }
753
-
754
- this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
755
- return { isValid: true };
756
- } finally {
757
- await fork.close();
758
- }
759
- }
760
-
761
- /**
762
- * Extract checkpoint global variables from a block.
763
- */
764
- private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
765
- const gv = block.header.globalVariables;
766
- return {
767
- chainId: gv.chainId,
768
- version: gv.version,
769
- slotNumber: gv.slotNumber,
770
- timestamp: gv.timestamp,
771
- coinbase: gv.coinbase,
772
- feeRecipient: gv.feeRecipient,
773
- gasFees: gv.gasFees,
774
- };
775
- }
776
-
777
642
  /**
778
643
  * Uploads blobs for a checkpoint to the filestore (fire and forget).
779
644
  */
@@ -878,6 +743,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
878
743
 
879
744
  async createBlockProposal(
880
745
  blockHeader: BlockHeader,
746
+ checkpointNumber: CheckpointNumber,
881
747
  indexWithinCheckpoint: IndexWithinCheckpoint,
882
748
  inHash: Fr,
883
749
  archive: Fr,
@@ -904,6 +770,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
904
770
  );
905
771
  const newProposal = await this.validationService.createBlockProposal(
906
772
  blockHeader,
773
+ checkpointNumber,
907
774
  indexWithinCheckpoint,
908
775
  inHash,
909
776
  archive,
@@ -921,8 +788,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
921
788
  async createCheckpointProposal(
922
789
  checkpointHeader: CheckpointHeader,
923
790
  archive: Fr,
791
+ checkpointNumber: CheckpointNumber,
924
792
  feeAssetPriceModifier: bigint,
925
- lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
793
+ lastBlockProposal: BlockProposal | undefined,
926
794
  proposerAddress: EthAddress | undefined,
927
795
  options: CheckpointProposalOptions = {},
928
796
  ): Promise<CheckpointProposal> {
@@ -943,8 +811,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
943
811
  const newProposal = await this.validationService.createCheckpointProposal(
944
812
  checkpointHeader,
945
813
  archive,
814
+ checkpointNumber,
946
815
  feeAssetPriceModifier,
947
- lastBlockInfo,
816
+ lastBlockProposal,
948
817
  proposerAddress,
949
818
  options,
950
819
  );
@@ -960,16 +829,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
960
829
  attestationsAndSigners: CommitteeAttestationsAndSigners,
961
830
  proposer: EthAddress,
962
831
  slot: SlotNumber,
963
- blockNumber: BlockNumber | CheckpointNumber,
832
+ checkpointNumber: CheckpointNumber,
964
833
  ): Promise<Signature> {
965
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
834
+ return await this.validationService.signAttestationsAndSigners(
835
+ attestationsAndSigners,
836
+ proposer,
837
+ slot,
838
+ checkpointNumber,
839
+ );
966
840
  }
967
841
 
968
- async collectOwnAttestations(proposal: CheckpointProposal): Promise<CheckpointAttestation[]> {
842
+ async collectOwnAttestations(
843
+ proposal: CheckpointProposal,
844
+ checkpointNumber: CheckpointNumber,
845
+ ): Promise<CheckpointAttestation[]> {
969
846
  const slot = proposal.slotNumber;
970
847
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
971
848
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
972
- const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
849
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
973
850
 
974
851
  if (!attestations) {
975
852
  return [];
@@ -988,6 +865,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
988
865
  proposal: CheckpointProposal,
989
866
  required: number,
990
867
  deadline: Date,
868
+ checkpointNumber: CheckpointNumber,
991
869
  ): Promise<CheckpointAttestation[]> {
992
870
  // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
993
871
  const slot = proposal.slotNumber;
@@ -1000,7 +878,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
1000
878
  throw new AttestationTimeoutError(0, required, slot);
1001
879
  }
1002
880
 
1003
- await this.collectOwnAttestations(proposal);
881
+ await this.collectOwnAttestations(proposal, checkpointNumber);
1004
882
 
1005
883
  const proposalId = proposal.archive.toString();
1006
884
  const myAddresses = this.getValidatorAddresses();
@@ -1013,7 +891,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
1013
891
  attestation => {
1014
892
  if (!attestation.archive.equals(proposal.archive)) {
1015
893
  this.log.warn(
1016
- `Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`,
894
+ `Received attestation for slot ${slot} with mismatched archive from ${attestation
895
+ .getSender()
896
+ ?.toString()}`,
1017
897
  { attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() },
1018
898
  );
1019
899
  return false;