@aztec/validator-client 0.0.1-commit.993d52e → 0.0.1-commit.9badcec54

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 +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 +5 -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 +6 -2
  19. package/dest/metrics.d.ts.map +1 -1
  20. package/dest/metrics.js +12 -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 +16 -21
  25. package/dest/validator.d.ts.map +1 -1
  26. package/dest/validator.js +78 -231
  27. package/package.json +19 -19
  28. package/src/checkpoint_builder.ts +124 -35
  29. package/src/config.ts +22 -6
  30. package/src/duties/validation_service.ts +17 -36
  31. package/src/factory.ts +10 -4
  32. package/src/index.ts +1 -1
  33. package/src/key_store/ha_key_store.ts +1 -1
  34. package/src/metrics.ts +19 -1
  35. package/src/proposal_handler.ts +1042 -0
  36. package/src/validator.ts +106 -263
  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,
@@ -42,23 +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';
50
44
  import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
51
45
 
52
46
  import { EventEmitter } from 'events';
53
47
  import type { TypedDataDefinition } from 'viem';
54
48
 
55
- import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
56
49
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
57
50
  import { ValidationService } from './duties/validation_service.js';
58
51
  import { HAKeyStore } from './key_store/ha_key_store.js';
59
52
  import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
60
53
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
61
54
  import { ValidatorMetrics } from './metrics.js';
55
+ import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.js';
62
56
 
63
57
  // We maintain a set of proposers who have proposed invalid blocks.
64
58
  // Just cap the set to avoid unbounded growth.
@@ -101,14 +95,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
101
95
  private keyStore: ExtendedValidatorKeyStore,
102
96
  private epochCache: EpochCache,
103
97
  private p2pClient: P2P,
104
- private blockProposalHandler: BlockProposalHandler,
98
+ private proposalHandler: ProposalHandler,
105
99
  private blockSource: L2BlockSource,
106
100
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
107
101
  private worldState: WorldStateSynchronizer,
108
102
  private l1ToL2MessageSource: L1ToL2MessageSource,
109
103
  private config: ValidatorClientFullConfig,
110
104
  private blobClient: BlobClientInterface,
111
- private haSigner: ValidatorHASigner | undefined,
105
+ private slashingProtectionSigner: ValidatorHASigner,
112
106
  private dateProvider: DateProvider = new DateProvider(),
113
107
  telemetry: TelemetryClient = getTelemetryClient(),
114
108
  log = createLogger('validator'),
@@ -196,13 +190,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
196
190
  blobClient: BlobClientInterface,
197
191
  dateProvider: DateProvider = new DateProvider(),
198
192
  telemetry: TelemetryClient = getTelemetryClient(),
193
+ slashingProtectionDb?: SlashingProtectionDatabase,
199
194
  ) {
200
195
  const metrics = new ValidatorMetrics(telemetry);
201
196
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
202
197
  txsPermitted: !config.disableTransactions,
203
- maxTxsPerBlock: config.maxTxsPerBlock,
198
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
204
199
  });
205
- const blockProposalHandler = new BlockProposalHandler(
200
+ const proposalHandler = new ProposalHandler(
206
201
  checkpointsBuilder,
207
202
  worldState,
208
203
  blockSource,
@@ -211,37 +206,54 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
211
206
  blockProposalValidator,
212
207
  epochCache,
213
208
  config,
209
+ blobClient,
214
210
  metrics,
215
211
  dateProvider,
216
212
  telemetry,
213
+ undefined,
217
214
  );
218
215
 
219
216
  const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
220
- let validatorKeyStore: ExtendedValidatorKeyStore = nodeKeystoreAdapter;
221
- let haSigner: ValidatorHASigner | undefined;
222
- if (config.haSigningEnabled) {
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.
223
226
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
224
227
  const haConfig = {
225
228
  ...config,
226
229
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
227
230
  };
228
- const { signer } = await createHASigner(haConfig);
229
- haSigner = signer;
230
- validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, 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
+ }));
231
242
  }
243
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
232
244
 
233
245
  const validator = new ValidatorClient(
234
246
  validatorKeyStore,
235
247
  epochCache,
236
248
  p2pClient,
237
- blockProposalHandler,
249
+ proposalHandler,
238
250
  blockSource,
239
251
  checkpointsBuilder,
240
252
  worldState,
241
253
  l1ToL2MessageSource,
242
254
  config,
243
255
  blobClient,
244
- haSigner,
256
+ slashingProtectionSigner,
245
257
  dateProvider,
246
258
  telemetry,
247
259
  );
@@ -255,8 +267,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
255
267
  .filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
256
268
  }
257
269
 
258
- public getBlockProposalHandler() {
259
- return this.blockProposalHandler;
270
+ public getProposalHandler() {
271
+ return this.proposalHandler;
260
272
  }
261
273
 
262
274
  public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
@@ -280,24 +292,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
280
292
  }
281
293
 
282
294
  public reloadKeystore(newManager: KeystoreManager): void {
283
- if (this.config.haSigningEnabled && !this.haSigner) {
284
- this.log.warn(
285
- 'HA signing is enabled in config but was not initialized at startup. ' +
286
- 'Restart the node to enable HA signing.',
287
- );
288
- } else if (!this.config.haSigningEnabled && this.haSigner) {
289
- this.log.warn(
290
- 'HA signing was disabled via config update but the HA signer is still active. ' +
291
- 'Restart the node to fully disable HA signing.',
292
- );
293
- }
294
-
295
295
  const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
296
- if (this.haSigner) {
297
- this.keyStore = new HAKeyStore(newAdapter, this.haSigner);
298
- } else {
299
- this.keyStore = newAdapter;
300
- }
296
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
301
297
  this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
302
298
  }
303
299
 
@@ -345,7 +341,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
345
341
  checkpoint: CheckpointProposalCore,
346
342
  proposalSender: PeerId,
347
343
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
348
- this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
344
+ this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
349
345
 
350
346
  // Duplicate proposal handler - triggers slashing for equivocation
351
347
  this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
@@ -384,13 +380,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
384
380
  return false;
385
381
  }
386
382
 
387
- // Ignore proposals from ourselves (may happen in HA setups)
383
+ // Log self-proposals from HA peers (same validator key on different nodes)
388
384
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
389
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
385
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
390
386
  proposer: proposer.toString(),
391
387
  slotNumber,
392
388
  });
393
- return false;
394
389
  }
395
390
 
396
391
  // Check if we're in the committee (for metrics purposes)
@@ -406,25 +401,25 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
406
401
 
407
402
  // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
408
403
  // In fisherman mode, we always reexecute to validate proposals.
409
- const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
410
- this.config;
404
+ const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
411
405
  const shouldReexecute =
412
406
  fishermanMode ||
413
- (slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
414
- (partOfCommittee && validatorReexecute) ||
407
+ slashBroadcastedInvalidBlockPenalty > 0n ||
408
+ partOfCommittee ||
415
409
  alwaysReexecuteBlockProposals ||
416
410
  this.blobClient.canUpload();
417
411
 
418
- const validationResult = await this.blockProposalHandler.handleBlockProposal(
412
+ const validationResult = await this.proposalHandler.handleBlockProposal(
419
413
  proposal,
420
414
  proposalSender,
421
415
  !!shouldReexecute && !escapeHatchOpen,
422
416
  );
423
417
 
424
418
  if (!validationResult.isValid) {
425
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
426
-
427
419
  const reason = validationResult.reason || 'unknown';
420
+
421
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
422
+
428
423
  // Classify failure reason: bad proposal vs node issue
429
424
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
430
425
  'invalid_proposal',
@@ -479,68 +474,51 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
479
474
  proposal: CheckpointProposalCore,
480
475
  _proposalSender: PeerId,
481
476
  ): Promise<CheckpointAttestation[] | undefined> {
482
- const slotNumber = proposal.slotNumber;
477
+ const proposalSlotNumber = proposal.slotNumber;
483
478
  const proposer = proposal.getSender();
484
479
 
485
480
  // If escape hatch is open for this slot's epoch, do not attest.
486
- if (await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber)) {
487
- this.log.warn(`Escape hatch open for slot ${slotNumber}, skipping checkpoint attestation handling`);
488
- return undefined;
489
- }
490
-
491
- // Reject proposals with invalid signatures
492
- if (!proposer) {
493
- 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`);
494
483
  return undefined;
495
484
  }
496
485
 
497
486
  // Ignore proposals from ourselves (may happen in HA setups)
498
- if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
499
- 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}`, {
500
489
  proposer: proposer.toString(),
501
- slotNumber,
490
+ proposalSlotNumber,
502
491
  });
503
492
  return undefined;
504
493
  }
505
494
 
506
- // Validate fee asset price modifier is within allowed range
507
- if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
508
- this.log.warn(
509
- `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`,
510
- );
511
- return undefined;
512
- }
513
-
514
- // Check that I have any address in current committee before attesting
515
- 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());
516
497
  const partOfCommittee = inCommittee.length > 0;
517
498
 
518
499
  const proposalInfo = {
519
- slotNumber,
500
+ proposalSlotNumber,
520
501
  archive: proposal.archive.toString(),
521
- proposer: proposer.toString(),
522
- txCount: proposal.txHashes.length,
502
+ proposer: proposer?.toString(),
523
503
  };
524
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
504
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
525
505
  ...proposalInfo,
526
- txHashes: proposal.txHashes.map(t => t.toString()),
527
506
  fishermanMode: this.config.fishermanMode || false,
528
507
  });
529
508
 
530
- // 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;
531
512
  if (this.config.skipCheckpointProposalValidation) {
532
- 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);
533
515
  } else {
534
- const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
516
+ const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
535
517
  if (!validationResult.isValid) {
536
518
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
537
519
  return undefined;
538
520
  }
539
- }
540
-
541
- // Upload blobs to filestore if we can (fire and forget)
542
- if (this.blobClient.canUpload()) {
543
- void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
521
+ checkpointNumber = validationResult.checkpointNumber;
544
522
  }
545
523
 
546
524
  // Check that I have any address in current committee before attesting
@@ -551,16 +529,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
551
529
  }
552
530
 
553
531
  // Provided all of the above checks pass, we can attest to the proposal
554
- this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, {
555
- ...proposalInfo,
556
- inCommittee: partOfCommittee,
557
- fishermanMode: this.config.fishermanMode || false,
558
- });
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
+ );
559
540
 
560
541
  this.metrics.incSuccessfulAttestations(inCommittee.length);
561
542
 
562
543
  // Track epoch participation per attester: count each (attester, epoch) pair at most once
563
- const proposalEpoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants());
544
+ const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
564
545
  for (const attester of inCommittee) {
565
546
  const key = attester.toString();
566
547
  const lastEpoch = this.lastAttestedEpochByAttester.get(key);
@@ -588,14 +569,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
588
569
 
589
570
  if (this.config.fishermanMode) {
590
571
  // bail out early and don't save attestations to the pool in fisherman mode
591
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
572
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
592
573
  ...proposalInfo,
593
574
  attestors: attestors.map(a => a.toString()),
594
575
  });
595
576
  return undefined;
596
577
  }
597
578
 
598
- return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
579
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
599
580
  }
600
581
 
601
582
  /**
@@ -622,13 +603,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
622
603
  private async createCheckpointAttestationsFromProposal(
623
604
  proposal: CheckpointProposalCore,
624
605
  attestors: EthAddress[] = [],
606
+ checkpointNumber: CheckpointNumber,
625
607
  ): Promise<CheckpointAttestation[] | undefined> {
626
608
  // Equivocation check: must happen right before signing to minimize the race window
627
609
  if (!this.shouldAttestToSlot(proposal.slotNumber)) {
628
610
  return undefined;
629
611
  }
630
612
 
631
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
613
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
632
614
 
633
615
  // Track the proposal we attested to (to prevent equivocation)
634
616
  this.lastAttestedProposal = proposal;
@@ -637,158 +619,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
637
619
  return attestations;
638
620
  }
639
621
 
640
- /**
641
- * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
642
- * @returns Validation result with isValid flag and reason if invalid.
643
- */
644
- private async validateCheckpointProposal(
645
- proposal: CheckpointProposalCore,
646
- proposalInfo: LogData,
647
- ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
648
- const slot = proposal.slotNumber;
649
-
650
- // Timeout block syncing at the start of the next slot
651
- const config = this.checkpointsBuilder.getConfig();
652
- const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
653
- const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
654
-
655
- // Wait for last block to sync by archive
656
- let lastBlockHeader: BlockHeader | undefined;
657
- try {
658
- lastBlockHeader = await retryUntil(
659
- async () => {
660
- await this.blockSource.syncImmediate();
661
- return this.blockSource.getBlockHeaderByArchive(proposal.archive);
662
- },
663
- `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
664
- timeoutSeconds,
665
- 0.5,
666
- );
667
- } catch (err) {
668
- if (err instanceof TimeoutError) {
669
- this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
670
- return { isValid: false, reason: 'last_block_not_found' };
671
- }
672
- this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
673
- return { isValid: false, reason: 'block_fetch_error' };
674
- }
675
-
676
- if (!lastBlockHeader) {
677
- this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
678
- return { isValid: false, reason: 'last_block_not_found' };
679
- }
680
-
681
- // Get all full blocks for the slot and checkpoint
682
- const blocks = await this.blockSource.getBlocksForSlot(slot);
683
- if (blocks.length === 0) {
684
- this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
685
- return { isValid: false, reason: 'no_blocks_for_slot' };
686
- }
687
-
688
- // Ensure the last block for this slot matches the archive in the checkpoint proposal
689
- if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
690
- this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
691
- return { isValid: false, reason: 'last_block_archive_mismatch' };
692
- }
693
-
694
- this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
695
- ...proposalInfo,
696
- blockNumbers: blocks.map(b => b.number),
697
- });
698
-
699
- // Get checkpoint constants from first block
700
- const firstBlock = blocks[0];
701
- const constants = this.extractCheckpointConstants(firstBlock);
702
- const checkpointNumber = firstBlock.checkpointNumber;
703
-
704
- // Get L1-to-L2 messages for this checkpoint
705
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
706
-
707
- // Collect the out hashes of all the checkpoints before this one in the same epoch
708
- const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
709
- const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
710
- .filter(c => c.checkpointNumber < checkpointNumber)
711
- .map(c => c.checkpointOutHash);
712
-
713
- // Fork world state at the block before the first block
714
- const parentBlockNumber = BlockNumber(firstBlock.number - 1);
715
- const fork = await this.worldState.fork(parentBlockNumber);
716
-
717
- try {
718
- // Create checkpoint builder with all existing blocks
719
- const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
720
- checkpointNumber,
721
- constants,
722
- proposal.feeAssetPriceModifier,
723
- l1ToL2Messages,
724
- previousCheckpointOutHashes,
725
- fork,
726
- blocks,
727
- this.log.getBindings(),
728
- );
729
-
730
- // Complete the checkpoint to get computed values
731
- const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
732
-
733
- // Compare checkpoint header with proposal
734
- if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
735
- this.log.warn(`Checkpoint header mismatch`, {
736
- ...proposalInfo,
737
- computed: computedCheckpoint.header.toInspect(),
738
- proposal: proposal.checkpointHeader.toInspect(),
739
- });
740
- return { isValid: false, reason: 'checkpoint_header_mismatch' };
741
- }
742
-
743
- // Compare archive root with proposal
744
- if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
745
- this.log.warn(`Archive root mismatch`, {
746
- ...proposalInfo,
747
- computed: computedCheckpoint.archive.root.toString(),
748
- proposal: proposal.archive.toString(),
749
- });
750
- return { isValid: false, reason: 'archive_mismatch' };
751
- }
752
-
753
- // Check that the accumulated epoch out hash matches the value in the proposal.
754
- // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
755
- const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
756
- const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
757
- const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
758
- if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
759
- this.log.warn(`Epoch out hash mismatch`, {
760
- proposalEpochOutHash: proposalEpochOutHash.toString(),
761
- computedEpochOutHash: computedEpochOutHash.toString(),
762
- checkpointOutHash: checkpointOutHash.toString(),
763
- previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
764
- ...proposalInfo,
765
- });
766
- return { isValid: false, reason: 'out_hash_mismatch' };
767
- }
768
-
769
- this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
770
- return { isValid: true };
771
- } finally {
772
- await fork.close();
773
- }
774
- }
775
-
776
- /**
777
- * Extract checkpoint global variables from a block.
778
- */
779
- private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
780
- const gv = block.header.globalVariables;
781
- return {
782
- chainId: gv.chainId,
783
- version: gv.version,
784
- slotNumber: gv.slotNumber,
785
- timestamp: gv.timestamp,
786
- coinbase: gv.coinbase,
787
- feeRecipient: gv.feeRecipient,
788
- gasFees: gv.gasFees,
789
- };
790
- }
791
-
792
622
  /**
793
623
  * Uploads blobs for a checkpoint to the filestore (fire and forget).
794
624
  */
@@ -893,6 +723,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
893
723
 
894
724
  async createBlockProposal(
895
725
  blockHeader: BlockHeader,
726
+ checkpointNumber: CheckpointNumber,
896
727
  indexWithinCheckpoint: IndexWithinCheckpoint,
897
728
  inHash: Fr,
898
729
  archive: Fr,
@@ -919,6 +750,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
919
750
  );
920
751
  const newProposal = await this.validationService.createBlockProposal(
921
752
  blockHeader,
753
+ checkpointNumber,
922
754
  indexWithinCheckpoint,
923
755
  inHash,
924
756
  archive,
@@ -936,8 +768,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
936
768
  async createCheckpointProposal(
937
769
  checkpointHeader: CheckpointHeader,
938
770
  archive: Fr,
771
+ checkpointNumber: CheckpointNumber,
939
772
  feeAssetPriceModifier: bigint,
940
- lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
773
+ lastBlockProposal: BlockProposal | undefined,
941
774
  proposerAddress: EthAddress | undefined,
942
775
  options: CheckpointProposalOptions = {},
943
776
  ): Promise<CheckpointProposal> {
@@ -958,8 +791,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
958
791
  const newProposal = await this.validationService.createCheckpointProposal(
959
792
  checkpointHeader,
960
793
  archive,
794
+ checkpointNumber,
961
795
  feeAssetPriceModifier,
962
- lastBlockInfo,
796
+ lastBlockProposal,
963
797
  proposerAddress,
964
798
  options,
965
799
  );
@@ -975,16 +809,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
975
809
  attestationsAndSigners: CommitteeAttestationsAndSigners,
976
810
  proposer: EthAddress,
977
811
  slot: SlotNumber,
978
- blockNumber: BlockNumber | CheckpointNumber,
812
+ checkpointNumber: CheckpointNumber,
979
813
  ): Promise<Signature> {
980
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
814
+ return await this.validationService.signAttestationsAndSigners(
815
+ attestationsAndSigners,
816
+ proposer,
817
+ slot,
818
+ checkpointNumber,
819
+ );
981
820
  }
982
821
 
983
- async collectOwnAttestations(proposal: CheckpointProposal): Promise<CheckpointAttestation[]> {
822
+ async collectOwnAttestations(
823
+ proposal: CheckpointProposal,
824
+ checkpointNumber: CheckpointNumber,
825
+ ): Promise<CheckpointAttestation[]> {
984
826
  const slot = proposal.slotNumber;
985
827
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
986
828
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
987
- const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
829
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
988
830
 
989
831
  if (!attestations) {
990
832
  return [];
@@ -1003,6 +845,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
1003
845
  proposal: CheckpointProposal,
1004
846
  required: number,
1005
847
  deadline: Date,
848
+ checkpointNumber: CheckpointNumber,
1006
849
  ): Promise<CheckpointAttestation[]> {
1007
850
  // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
1008
851
  const slot = proposal.slotNumber;
@@ -1015,7 +858,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
1015
858
  throw new AttestationTimeoutError(0, required, slot);
1016
859
  }
1017
860
 
1018
- await this.collectOwnAttestations(proposal);
861
+ await this.collectOwnAttestations(proposal, checkpointNumber);
1019
862
 
1020
863
  const proposalId = proposal.archive.toString();
1021
864
  const myAddresses = this.getValidatorAddresses();