@aztec/validator-client 4.0.0-devnet.2-patch.3 → 4.0.0-devnet.3-patch.0

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 (50) hide show
  1. package/README.md +41 -0
  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 +20 -0
  8. package/dest/duties/validation_service.d.ts +1 -1
  9. package/dest/duties/validation_service.d.ts.map +1 -1
  10. package/dest/duties/validation_service.js +3 -9
  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 +10 -2
  19. package/dest/metrics.d.ts.map +1 -1
  20. package/dest/metrics.js +12 -0
  21. package/dest/proposal_handler.d.ts +94 -0
  22. package/dest/proposal_handler.d.ts.map +1 -0
  23. package/dest/{block_proposal_handler.js → proposal_handler.js} +356 -36
  24. package/dest/validator.d.ts +11 -22
  25. package/dest/validator.d.ts.map +1 -1
  26. package/dest/validator.js +41 -217
  27. package/package.json +19 -19
  28. package/src/checkpoint_builder.ts +135 -39
  29. package/src/config.ts +20 -0
  30. package/src/duties/validation_service.ts +3 -9
  31. package/src/factory.ts +9 -3
  32. package/src/index.ts +1 -2
  33. package/src/key_store/ha_key_store.ts +1 -1
  34. package/src/metrics.ts +19 -1
  35. package/src/{block_proposal_handler.ts → proposal_handler.ts} +412 -44
  36. package/src/validator.ts +48 -240
  37. package/dest/block_proposal_handler.d.ts +0 -63
  38. package/dest/block_proposal_handler.d.ts.map +0 -1
  39. package/dest/tx_validator/index.d.ts +0 -3
  40. package/dest/tx_validator/index.d.ts.map +0 -1
  41. package/dest/tx_validator/index.js +0 -2
  42. package/dest/tx_validator/nullifier_cache.d.ts +0 -14
  43. package/dest/tx_validator/nullifier_cache.d.ts.map +0 -1
  44. package/dest/tx_validator/nullifier_cache.js +0 -24
  45. package/dest/tx_validator/tx_validator_factory.d.ts +0 -19
  46. package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
  47. package/dest/tx_validator/tx_validator_factory.js +0 -54
  48. package/src/tx_validator/index.ts +0 -2
  49. package/src/tx_validator/nullifier_cache.ts +0 -30
  50. package/src/tx_validator/tx_validator_factory.ts +0 -154
@@ -1,21 +1,34 @@
1
+ import type { BlobClientInterface } from '@aztec/blob-client/client';
2
+ import { type Blob, encodeCheckpointBlobDataFromBlocks, getBlobsPerL1Block } from '@aztec/blob-lib';
1
3
  import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
2
4
  import type { EpochCache } from '@aztec/epoch-cache';
5
+ import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
3
6
  import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
7
+ import { pick } from '@aztec/foundation/collection';
4
8
  import { Fr } from '@aztec/foundation/curves/bn254';
5
9
  import { TimeoutError } from '@aztec/foundation/error';
10
+ import type { LogData } from '@aztec/foundation/log';
6
11
  import { createLogger } from '@aztec/foundation/log';
7
12
  import { retryUntil } from '@aztec/foundation/retry';
8
13
  import { DateProvider, Timer } from '@aztec/foundation/timer';
9
14
  import type { P2P, PeerId } from '@aztec/p2p';
10
15
  import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
11
16
  import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
17
+ import { validateCheckpoint } from '@aztec/stdlib/checkpoint';
12
18
  import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
19
+ import { Gas } from '@aztec/stdlib/gas';
13
20
  import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
14
- import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
15
- import type { BlockProposal } from '@aztec/stdlib/p2p';
21
+ import {
22
+ type L1ToL2MessageSource,
23
+ accumulateCheckpointOutHashes,
24
+ computeInHashFromL1ToL2Messages,
25
+ } from '@aztec/stdlib/messaging';
26
+ import type { BlockProposal, CheckpointProposalCore } from '@aztec/stdlib/p2p';
27
+ import { MerkleTreeId } from '@aztec/stdlib/trees';
16
28
  import type { CheckpointGlobalVariables, FailedTx, Tx } from '@aztec/stdlib/tx';
17
29
  import {
18
30
  ReExFailedTxsError,
31
+ ReExInitialStateMismatchError,
19
32
  ReExStateMismatchError,
20
33
  ReExTimeoutError,
21
34
  TransactionsNotAvailableError,
@@ -28,6 +41,7 @@ import type { ValidatorMetrics } from './metrics.js';
28
41
  export type BlockProposalValidationFailureReason =
29
42
  | 'invalid_proposal'
30
43
  | 'parent_block_not_found'
44
+ | 'block_source_not_synced'
31
45
  | 'parent_block_wrong_slot'
32
46
  | 'in_hash_mismatch'
33
47
  | 'global_variables_mismatch'
@@ -35,6 +49,7 @@ export type BlockProposalValidationFailureReason =
35
49
  | 'txs_not_available'
36
50
  | 'state_mismatch'
37
51
  | 'failed_txs'
52
+ | 'initial_state_mismatch'
38
53
  | 'timeout'
39
54
  | 'unknown_error';
40
55
 
@@ -60,11 +75,14 @@ export type BlockProposalValidationFailureResult = {
60
75
 
61
76
  export type BlockProposalValidationResult = BlockProposalValidationSuccessResult | BlockProposalValidationFailureResult;
62
77
 
78
+ export type CheckpointProposalValidationResult = { isValid: true } | { isValid: false; reason: string };
79
+
63
80
  type CheckpointComputationResult =
64
81
  | { checkpointNumber: CheckpointNumber; reason?: undefined }
65
82
  | { checkpointNumber?: undefined; reason: 'invalid_proposal' | 'global_variables_mismatch' };
66
83
 
67
- export class BlockProposalHandler {
84
+ /** Handles block and checkpoint proposals for both validator and non-validator nodes. */
85
+ export class ProposalHandler {
68
86
  public readonly tracer: Tracer;
69
87
 
70
88
  constructor(
@@ -76,36 +94,44 @@ export class BlockProposalHandler {
76
94
  private blockProposalValidator: BlockProposalValidator,
77
95
  private epochCache: EpochCache,
78
96
  private config: ValidatorClientFullConfig,
97
+ private blobClient: BlobClientInterface,
79
98
  private metrics?: ValidatorMetrics,
80
99
  private dateProvider: DateProvider = new DateProvider(),
81
100
  telemetry: TelemetryClient = getTelemetryClient(),
82
- private log = createLogger('validator:block-proposal-handler'),
101
+ private log = createLogger('validator:proposal-handler'),
83
102
  ) {
84
103
  if (config.fishermanMode) {
85
104
  this.log = this.log.createChild('[FISHERMAN]');
86
105
  }
87
- this.tracer = telemetry.getTracer('BlockProposalHandler');
106
+ this.tracer = telemetry.getTracer('ProposalHandler');
88
107
  }
89
108
 
90
- registerForReexecution(p2pClient: P2P): BlockProposalHandler {
91
- // Non-validator handler that re-executes for monitoring but does not attest.
109
+ /**
110
+ * Registers non-validator handlers for block and checkpoint proposals on the p2p client.
111
+ * Block proposals are always registered. Checkpoint proposals are registered if the blob client can upload.
112
+ */
113
+ register(p2pClient: P2P, shouldReexecute: boolean): ProposalHandler {
114
+ // Non-validator handler that processes or re-executes for monitoring but does not attest.
92
115
  // Returns boolean indicating whether the proposal was valid.
93
- const handler = async (proposal: BlockProposal, proposalSender: PeerId): Promise<boolean> => {
116
+ const blockHandler = async (proposal: BlockProposal, proposalSender: PeerId): Promise<boolean> => {
94
117
  try {
95
- const result = await this.handleBlockProposal(proposal, proposalSender, true);
118
+ const { slotNumber, blockNumber } = proposal;
119
+ const result = await this.handleBlockProposal(proposal, proposalSender, shouldReexecute);
96
120
  if (result.isValid) {
97
- this.log.info(`Non-validator reexecution completed for slot ${proposal.slotNumber}`, {
121
+ this.log.info(`Non-validator block proposal ${blockNumber} at slot ${slotNumber} handled`, {
98
122
  blockNumber: result.blockNumber,
123
+ slotNumber,
99
124
  reexecutionTimeMs: result.reexecutionResult?.reexecutionTimeMs,
100
125
  totalManaUsed: result.reexecutionResult?.totalManaUsed,
101
126
  numTxs: result.reexecutionResult?.block?.body?.txEffects?.length ?? 0,
127
+ reexecuted: shouldReexecute,
102
128
  });
103
129
  return true;
104
130
  } else {
105
- this.log.warn(`Non-validator reexecution failed for slot ${proposal.slotNumber}`, {
106
- blockNumber: result.blockNumber,
107
- reason: result.reason,
108
- });
131
+ this.log.warn(
132
+ `Non-validator block proposal ${blockNumber} at slot ${slotNumber} failed processing with ${result.reason}`,
133
+ { blockNumber: result.blockNumber, slotNumber, reason: result.reason },
134
+ );
109
135
  return false;
110
136
  }
111
137
  } catch (error) {
@@ -114,7 +140,35 @@ export class BlockProposalHandler {
114
140
  }
115
141
  };
116
142
 
117
- p2pClient.registerBlockProposalHandler(handler);
143
+ p2pClient.registerBlockProposalHandler(blockHandler);
144
+
145
+ // Register checkpoint proposal handler if blob uploads are enabled and we are reexecuting
146
+ if (this.blobClient.canUpload() && shouldReexecute) {
147
+ const checkpointHandler = async (checkpoint: CheckpointProposalCore, _sender: PeerId) => {
148
+ try {
149
+ const proposalInfo = {
150
+ proposalSlotNumber: checkpoint.slotNumber,
151
+ archive: checkpoint.archive.toString(),
152
+ proposer: checkpoint.getSender()?.toString(),
153
+ };
154
+ const result = await this.handleCheckpointProposal(checkpoint, proposalInfo);
155
+ if (result.isValid) {
156
+ this.log.info(`Non-validator checkpoint proposal at slot ${checkpoint.slotNumber} handled`, proposalInfo);
157
+ } else {
158
+ this.log.warn(
159
+ `Non-validator checkpoint proposal at slot ${checkpoint.slotNumber} failed: ${result.reason}`,
160
+ proposalInfo,
161
+ );
162
+ }
163
+ } catch (error) {
164
+ this.log.error('Error processing checkpoint proposal in non-validator handler', error);
165
+ }
166
+ // Non-validators don't attest
167
+ return undefined;
168
+ };
169
+ p2pClient.registerCheckpointProposalHandler(checkpointHandler);
170
+ }
171
+
118
172
  return this;
119
173
  }
120
174
 
@@ -133,7 +187,13 @@ export class BlockProposalHandler {
133
187
  return { isValid: false, reason: 'invalid_proposal' };
134
188
  }
135
189
 
136
- const proposalInfo = { ...proposal.toBlockInfo(), proposer: proposer.toString() };
190
+ const proposalInfo = {
191
+ ...proposal.toBlockInfo(),
192
+ proposer: proposer.toString(),
193
+ blockNumber: undefined as BlockNumber | undefined,
194
+ checkpointNumber: undefined as CheckpointNumber | undefined,
195
+ };
196
+
137
197
  this.log.info(`Processing proposal for slot ${slotNumber}`, {
138
198
  ...proposalInfo,
139
199
  txHashes: proposal.txHashes.map(t => t.toString()),
@@ -147,7 +207,20 @@ export class BlockProposalHandler {
147
207
  return { isValid: false, reason: 'invalid_proposal' };
148
208
  }
149
209
 
150
- // Check that the parent proposal is a block we know, otherwise reexecution would fail
210
+ // Ensure the block source is synced before checking for existing blocks,
211
+ // since a pending checkpoint prune may remove blocks we'd otherwise find.
212
+ // This affects mostly the block_number_already_exists check, since a pending
213
+ // checkpoint prune could remove a block that would conflict with this proposal.
214
+ // TODO(@Maddiaa0): This may break staggered slots.
215
+ const blockSourceSync = await this.waitForBlockSourceSync(slotNumber);
216
+ if (!blockSourceSync) {
217
+ this.log.warn(`Block source is not synced, skipping processing`, proposalInfo);
218
+ return { isValid: false, reason: 'block_source_not_synced' };
219
+ }
220
+
221
+ // Check that the parent proposal is a block we know, otherwise reexecution would fail.
222
+ // If we don't find it immediately, we keep retrying for a while; it may be we still
223
+ // need to process other block proposals to get to it.
151
224
  const parentBlock = await this.getParentBlock(proposal);
152
225
  if (parentBlock === undefined) {
153
226
  this.log.warn(`Parent block for proposal not found, skipping processing`, proposalInfo);
@@ -169,6 +242,7 @@ export class BlockProposalHandler {
169
242
  parentBlock === 'genesis'
170
243
  ? BlockNumber(INITIAL_L2_BLOCK_NUM)
171
244
  : BlockNumber(parentBlock.header.getBlockNumber() + 1);
245
+ proposalInfo.blockNumber = blockNumber;
172
246
 
173
247
  // Check that this block number does not exist already
174
248
  const existingBlock = await this.blockSource.getBlockHeader(blockNumber);
@@ -184,12 +258,22 @@ export class BlockProposalHandler {
184
258
  deadline: this.getReexecutionDeadline(slotNumber, config),
185
259
  });
186
260
 
261
+ // If reexecution is disabled, bail. We were just interested in triggering tx collection.
262
+ if (!shouldReexecute) {
263
+ this.log.info(
264
+ `Received valid block ${blockNumber} proposal at index ${proposal.indexWithinCheckpoint} on slot ${slotNumber}`,
265
+ proposalInfo,
266
+ );
267
+ return { isValid: true, blockNumber };
268
+ }
269
+
187
270
  // Compute the checkpoint number for this block and validate checkpoint consistency
188
271
  const checkpointResult = this.computeCheckpointNumber(proposal, parentBlock, proposalInfo);
189
272
  if (checkpointResult.reason) {
190
273
  return { isValid: false, blockNumber, reason: checkpointResult.reason };
191
274
  }
192
275
  const checkpointNumber = checkpointResult.checkpointNumber;
276
+ proposalInfo.checkpointNumber = checkpointNumber;
193
277
 
194
278
  // Check that I have the same set of l1ToL2Messages as the proposal
195
279
  const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
@@ -210,30 +294,28 @@ export class BlockProposalHandler {
210
294
  return { isValid: false, blockNumber, reason: 'txs_not_available' };
211
295
  }
212
296
 
297
+ // Collect the out hashes of all the checkpoints before this one in the same epoch
298
+ const epoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants());
299
+ const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
300
+ .filter(c => c.checkpointNumber < checkpointNumber)
301
+ .map(c => c.checkpointOutHash);
302
+
213
303
  // Try re-executing the transactions in the proposal if needed
214
304
  let reexecutionResult;
215
- if (shouldReexecute) {
216
- // Collect the out hashes of all the checkpoints before this one in the same epoch
217
- const epoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants());
218
- const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
219
- .filter(c => c.checkpointNumber < checkpointNumber)
220
- .map(c => c.checkpointOutHash);
221
-
222
- try {
223
- this.log.verbose(`Re-executing transactions in the proposal`, proposalInfo);
224
- reexecutionResult = await this.reexecuteTransactions(
225
- proposal,
226
- blockNumber,
227
- checkpointNumber,
228
- txs,
229
- l1ToL2Messages,
230
- previousCheckpointOutHashes,
231
- );
232
- } catch (error) {
233
- this.log.error(`Error reexecuting txs while processing block proposal`, error, proposalInfo);
234
- const reason = this.getReexecuteFailureReason(error);
235
- return { isValid: false, blockNumber, reason, reexecutionResult };
236
- }
305
+ try {
306
+ this.log.verbose(`Re-executing transactions in the proposal`, proposalInfo);
307
+ reexecutionResult = await this.reexecuteTransactions(
308
+ proposal,
309
+ blockNumber,
310
+ checkpointNumber,
311
+ txs,
312
+ l1ToL2Messages,
313
+ previousCheckpointOutHashes,
314
+ );
315
+ } catch (error) {
316
+ this.log.error(`Error reexecuting txs while processing block proposal`, error, proposalInfo);
317
+ const reason = this.getReexecuteFailureReason(error);
318
+ return { isValid: false, blockNumber, reason, reexecutionResult };
237
319
  }
238
320
 
239
321
  // If we succeeded, push this block into the archiver (unless disabled)
@@ -242,8 +324,8 @@ export class BlockProposalHandler {
242
324
  }
243
325
 
244
326
  this.log.info(
245
- `Successfully processed block ${blockNumber} proposal at index ${proposal.indexWithinCheckpoint} on slot ${slotNumber}`,
246
- proposalInfo,
327
+ `Successfully re-executed block ${blockNumber} proposal at index ${proposal.indexWithinCheckpoint} on slot ${slotNumber}`,
328
+ { ...proposalInfo, ...pick(reexecutionResult, 'reexecutionTimeMs', 'totalManaUsed') },
247
329
  );
248
330
 
249
331
  return { isValid: true, blockNumber, reexecutionResult };
@@ -413,8 +495,48 @@ export class BlockProposalHandler {
413
495
  return new Date(nextSlotTimestampSeconds * 1000);
414
496
  }
415
497
 
416
- private getReexecuteFailureReason(err: any) {
417
- if (err instanceof ReExStateMismatchError) {
498
+ /** Waits for the block source to sync L1 data up to at least the slot before the given one. */
499
+ private async waitForBlockSourceSync(slot: SlotNumber): Promise<boolean> {
500
+ const deadline = this.getReexecutionDeadline(slot, this.checkpointsBuilder.getConfig());
501
+ const timeoutMs = deadline.getTime() - this.dateProvider.now();
502
+ if (slot === 0) {
503
+ return true;
504
+ }
505
+
506
+ // Make a quick check before triggering an archiver sync
507
+ const syncedSlot = await this.blockSource.getSyncedL2SlotNumber();
508
+ if (syncedSlot !== undefined && syncedSlot + 1 >= slot) {
509
+ return true;
510
+ }
511
+
512
+ try {
513
+ // Trigger an immediate sync of the block source, and wait until it reports being synced to the required slot
514
+ return await retryUntil(
515
+ async () => {
516
+ await this.blockSource.syncImmediate();
517
+ const syncedSlot = await this.blockSource.getSyncedL2SlotNumber();
518
+ return syncedSlot !== undefined && syncedSlot + 1 >= slot;
519
+ },
520
+ 'wait for block source sync',
521
+ timeoutMs / 1000,
522
+ 0.5,
523
+ );
524
+ } catch (err) {
525
+ if (err instanceof TimeoutError) {
526
+ this.log.warn(`Timed out waiting for block source to sync to slot ${slot}`);
527
+ return false;
528
+ } else {
529
+ throw err;
530
+ }
531
+ }
532
+ }
533
+
534
+ private getReexecuteFailureReason(err: any): BlockProposalValidationFailureReason {
535
+ if (err instanceof TransactionsNotAvailableError) {
536
+ return 'txs_not_available';
537
+ } else if (err instanceof ReExInitialStateMismatchError) {
538
+ return 'initial_state_mismatch';
539
+ } else if (err instanceof ReExStateMismatchError) {
418
540
  return 'state_mismatch';
419
541
  } else if (err instanceof ReExFailedTxsError) {
420
542
  return 'failed_txs';
@@ -455,6 +577,13 @@ export class BlockProposalHandler {
455
577
  await this.worldState.syncImmediate(parentBlockNumber);
456
578
  await using fork = await this.worldState.fork(parentBlockNumber);
457
579
 
580
+ // Verify the fork's archive root matches the proposal's expected last archive.
581
+ // If they don't match, our world state synced to a different chain and reexecution would fail.
582
+ const forkArchiveRoot = new Fr((await fork.getTreeInfo(MerkleTreeId.ARCHIVE)).root);
583
+ if (!forkArchiveRoot.equals(proposal.blockHeader.lastArchive.root)) {
584
+ throw new ReExInitialStateMismatchError(proposal.blockHeader.lastArchive.root, forkArchiveRoot);
585
+ }
586
+
458
587
  // Build checkpoint constants from proposal (excludes blockNumber which is per-block)
459
588
  const constants: CheckpointGlobalVariables = {
460
589
  chainId: new Fr(config.l1ChainId),
@@ -480,18 +609,27 @@ export class BlockProposalHandler {
480
609
 
481
610
  // Build the new block
482
611
  const deadline = this.getReexecutionDeadline(slot, config);
612
+ const maxBlockGas =
613
+ this.config.validateMaxL2BlockGas !== undefined || this.config.validateMaxDABlockGas !== undefined
614
+ ? new Gas(this.config.validateMaxDABlockGas ?? Infinity, this.config.validateMaxL2BlockGas ?? Infinity)
615
+ : undefined;
483
616
  const result = await checkpointBuilder.buildBlock(txs, blockNumber, blockHeader.globalVariables.timestamp, {
617
+ isBuildingProposal: false,
618
+ minValidTxs: 0,
484
619
  deadline,
485
620
  expectedEndState: blockHeader.state,
621
+ maxTransactions: this.config.validateMaxTxsPerBlock,
622
+ maxBlockGas,
486
623
  });
487
624
 
488
625
  const { block, failedTxs } = result;
489
626
  const numFailedTxs = failedTxs.length;
490
627
 
491
- this.log.verbose(`Transaction re-execution complete for slot ${slot}`, {
628
+ this.log.verbose(`Block proposal ${blockNumber} at slot ${slot} transaction re-execution complete`, {
492
629
  numFailedTxs,
493
630
  numProposalTxs: txHashes.length,
494
631
  numProcessedTxs: block.body.txEffects.length,
632
+ blockNumber,
495
633
  slot,
496
634
  });
497
635
 
@@ -532,4 +670,234 @@ export class BlockProposalHandler {
532
670
  totalManaUsed,
533
671
  };
534
672
  }
673
+
674
+ /**
675
+ * Validates a checkpoint proposal and uploads blobs if configured.
676
+ * Used by both non-validator nodes (via register) and the validator client (via delegation).
677
+ */
678
+ async handleCheckpointProposal(
679
+ proposal: CheckpointProposalCore,
680
+ proposalInfo: LogData,
681
+ ): Promise<CheckpointProposalValidationResult> {
682
+ const proposer = proposal.getSender();
683
+ if (!proposer) {
684
+ this.log.warn(`Received checkpoint proposal with invalid signature for slot ${proposal.slotNumber}`);
685
+ return { isValid: false, reason: 'invalid_signature' };
686
+ }
687
+
688
+ if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
689
+ this.log.warn(
690
+ `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${proposal.slotNumber}`,
691
+ );
692
+ return { isValid: false, reason: 'invalid_fee_asset_price_modifier' };
693
+ }
694
+
695
+ const result = await this.validateCheckpointProposal(proposal, proposalInfo);
696
+
697
+ // Upload blobs to filestore if validation passed (fire and forget)
698
+ if (result.isValid) {
699
+ this.tryUploadBlobsForCheckpoint(proposal, proposalInfo);
700
+ }
701
+
702
+ return result;
703
+ }
704
+
705
+ /**
706
+ * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
707
+ * @returns Validation result with isValid flag and reason if invalid.
708
+ */
709
+ async validateCheckpointProposal(
710
+ proposal: CheckpointProposalCore,
711
+ proposalInfo: LogData,
712
+ ): Promise<CheckpointProposalValidationResult> {
713
+ const slot = proposal.slotNumber;
714
+
715
+ // Timeout block syncing at the start of the next slot
716
+ const config = this.checkpointsBuilder.getConfig();
717
+ const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
718
+ const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
719
+
720
+ // Wait for last block to sync by archive
721
+ let lastBlockHeader;
722
+ try {
723
+ lastBlockHeader = await retryUntil(
724
+ async () => {
725
+ await this.blockSource.syncImmediate();
726
+ return this.blockSource.getBlockHeaderByArchive(proposal.archive);
727
+ },
728
+ `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
729
+ timeoutSeconds,
730
+ 0.5,
731
+ );
732
+ } catch (err) {
733
+ if (err instanceof TimeoutError) {
734
+ this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
735
+ return { isValid: false, reason: 'last_block_not_found' };
736
+ }
737
+ this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
738
+ return { isValid: false, reason: 'block_fetch_error' };
739
+ }
740
+
741
+ if (!lastBlockHeader) {
742
+ this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
743
+ return { isValid: false, reason: 'last_block_not_found' };
744
+ }
745
+
746
+ // Get all full blocks for the slot and checkpoint
747
+ const blocks = await this.blockSource.getBlocksForSlot(slot);
748
+ if (blocks.length === 0) {
749
+ this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
750
+ return { isValid: false, reason: 'no_blocks_for_slot' };
751
+ }
752
+
753
+ // Ensure the last block for this slot matches the archive in the checkpoint proposal
754
+ if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
755
+ this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
756
+ return { isValid: false, reason: 'last_block_archive_mismatch' };
757
+ }
758
+
759
+ this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
760
+ ...proposalInfo,
761
+ blockNumbers: blocks.map(b => b.number),
762
+ });
763
+
764
+ // Get checkpoint constants from first block
765
+ const firstBlock = blocks[0];
766
+ const constants = this.extractCheckpointConstants(firstBlock);
767
+ const checkpointNumber = firstBlock.checkpointNumber;
768
+
769
+ // Get L1-to-L2 messages for this checkpoint
770
+ const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
771
+
772
+ // Collect the out hashes of all the checkpoints before this one in the same epoch
773
+ const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
774
+ const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
775
+ .filter(c => c.checkpointNumber < checkpointNumber)
776
+ .map(c => c.checkpointOutHash);
777
+
778
+ // Fork world state at the block before the first block
779
+ const parentBlockNumber = BlockNumber(firstBlock.number - 1);
780
+ const fork = await this.worldState.fork(parentBlockNumber);
781
+
782
+ try {
783
+ // Create checkpoint builder with all existing blocks
784
+ const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
785
+ checkpointNumber,
786
+ constants,
787
+ proposal.feeAssetPriceModifier,
788
+ l1ToL2Messages,
789
+ previousCheckpointOutHashes,
790
+ fork,
791
+ blocks,
792
+ this.log.getBindings(),
793
+ );
794
+
795
+ // Complete the checkpoint to get computed values
796
+ const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
797
+
798
+ // Compare checkpoint header with proposal
799
+ if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
800
+ this.log.warn(`Checkpoint header mismatch`, {
801
+ ...proposalInfo,
802
+ computed: computedCheckpoint.header.toInspect(),
803
+ proposal: proposal.checkpointHeader.toInspect(),
804
+ });
805
+ return { isValid: false, reason: 'checkpoint_header_mismatch' };
806
+ }
807
+
808
+ // Compare archive root with proposal
809
+ if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
810
+ this.log.warn(`Archive root mismatch`, {
811
+ ...proposalInfo,
812
+ computed: computedCheckpoint.archive.root.toString(),
813
+ proposal: proposal.archive.toString(),
814
+ });
815
+ return { isValid: false, reason: 'archive_mismatch' };
816
+ }
817
+
818
+ // Check that the accumulated epoch out hash matches the value in the proposal.
819
+ // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
820
+ const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
821
+ const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
822
+ const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
823
+ if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
824
+ this.log.warn(`Epoch out hash mismatch`, {
825
+ proposalEpochOutHash: proposalEpochOutHash.toString(),
826
+ computedEpochOutHash: computedEpochOutHash.toString(),
827
+ checkpointOutHash: checkpointOutHash.toString(),
828
+ previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
829
+ ...proposalInfo,
830
+ });
831
+ return { isValid: false, reason: 'out_hash_mismatch' };
832
+ }
833
+
834
+ // Final round of validations on the checkpoint, just in case.
835
+ try {
836
+ validateCheckpoint(computedCheckpoint, {
837
+ rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit,
838
+ maxDABlockGas: this.config.validateMaxDABlockGas,
839
+ maxL2BlockGas: this.config.validateMaxL2BlockGas,
840
+ maxTxsPerBlock: this.config.validateMaxTxsPerBlock,
841
+ maxTxsPerCheckpoint: this.config.validateMaxTxsPerCheckpoint,
842
+ });
843
+ } catch (err) {
844
+ this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo);
845
+ return { isValid: false, reason: 'checkpoint_validation_failed' };
846
+ }
847
+
848
+ this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
849
+ return { isValid: true };
850
+ } finally {
851
+ await fork.close();
852
+ }
853
+ }
854
+
855
+ /** Extracts checkpoint global variables from a block. */
856
+ private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
857
+ const gv = block.header.globalVariables;
858
+ return {
859
+ chainId: gv.chainId,
860
+ version: gv.version,
861
+ slotNumber: gv.slotNumber,
862
+ timestamp: gv.timestamp,
863
+ coinbase: gv.coinbase,
864
+ feeRecipient: gv.feeRecipient,
865
+ gasFees: gv.gasFees,
866
+ };
867
+ }
868
+
869
+ /** Triggers blob upload for a checkpoint if the blob client can upload (fire and forget). */
870
+ protected tryUploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): void {
871
+ if (this.blobClient.canUpload()) {
872
+ void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
873
+ }
874
+ }
875
+
876
+ /** Uploads blobs for a checkpoint to the filestore. */
877
+ protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
878
+ try {
879
+ const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
880
+ if (!lastBlockHeader) {
881
+ this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
882
+ return;
883
+ }
884
+
885
+ const blocks = await this.blockSource.getBlocksForSlot(proposal.slotNumber);
886
+ if (blocks.length === 0) {
887
+ this.log.warn(`No blocks found for blob upload`, proposalInfo);
888
+ return;
889
+ }
890
+
891
+ const blockBlobData = blocks.map(b => b.toBlockBlobData());
892
+ const blobFields = encodeCheckpointBlobDataFromBlocks(blockBlobData);
893
+ const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
894
+ await this.blobClient.sendBlobsToFilestore(blobs);
895
+ this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
896
+ ...proposalInfo,
897
+ numBlobs: blobs.length,
898
+ });
899
+ } catch (err) {
900
+ this.log.warn(`Failed to upload blobs for checkpoint: ${err}`, proposalInfo);
901
+ }
902
+ }
535
903
  }