@aztec/validator-client 0.0.1-commit.88e6f9396 → 0.0.1-commit.8c0b8ff

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.
package/src/config.ts CHANGED
@@ -6,8 +6,8 @@ import {
6
6
  secretValueConfigHelper,
7
7
  } from '@aztec/foundation/config';
8
8
  import { EthAddress } from '@aztec/foundation/eth-address';
9
- import { localSignerConfigMappings, validatorHASignerConfigMappings } from '@aztec/stdlib/ha-signing';
10
9
  import type { ValidatorClientConfig } from '@aztec/stdlib/interfaces/server';
10
+ import { validatorHASignerConfigMappings } from '@aztec/validator-ha-signer/config';
11
11
 
12
12
  export type { ValidatorClientConfig };
13
13
 
@@ -97,7 +97,6 @@ export const validatorClientConfigMappings: ConfigMappingsType<ValidatorClientCo
97
97
  description: 'Maximum transactions per checkpoint for validation. Proposals exceeding this limit are rejected.',
98
98
  parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined),
99
99
  },
100
- ...localSignerConfigMappings,
101
100
  ...validatorHASignerConfigMappings,
102
101
  };
103
102
 
package/src/factory.ts CHANGED
@@ -9,12 +9,12 @@ import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
9
9
  import type { TelemetryClient } from '@aztec/telemetry-client';
10
10
  import type { SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
11
11
 
12
- import { BlockProposalHandler } from './block_proposal_handler.js';
13
12
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
14
13
  import { ValidatorMetrics } from './metrics.js';
14
+ import { ProposalHandler } from './proposal_handler.js';
15
15
  import { ValidatorClient } from './validator.js';
16
16
 
17
- export function createBlockProposalHandler(
17
+ export function createProposalHandler(
18
18
  config: ValidatorClientFullConfig,
19
19
  deps: {
20
20
  checkpointsBuilder: FullNodeCheckpointsBuilder;
@@ -23,6 +23,7 @@ export function createBlockProposalHandler(
23
23
  l1ToL2MessageSource: L1ToL2MessageSource;
24
24
  p2pClient: P2PClient;
25
25
  epochCache: EpochCache;
26
+ blobClient: BlobClientInterface;
26
27
  dateProvider: DateProvider;
27
28
  telemetry: TelemetryClient;
28
29
  },
@@ -32,7 +33,7 @@ export function createBlockProposalHandler(
32
33
  txsPermitted: !config.disableTransactions,
33
34
  maxTxsPerBlock: config.validateMaxTxsPerBlock ?? config.validateMaxTxsPerCheckpoint,
34
35
  });
35
- return new BlockProposalHandler(
36
+ return new ProposalHandler(
36
37
  deps.checkpointsBuilder,
37
38
  deps.worldState,
38
39
  deps.blockSource,
@@ -41,6 +42,7 @@ export function createBlockProposalHandler(
41
42
  blockProposalValidator,
42
43
  deps.epochCache,
43
44
  config,
45
+ deps.blobClient,
44
46
  metrics,
45
47
  deps.dateProvider,
46
48
  deps.telemetry,
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export * from './block_proposal_handler.js';
1
+ export * from './proposal_handler.js';
2
2
  export * from './checkpoint_builder.js';
3
3
  export * from './config.js';
4
4
  export * from './factory.js';
package/src/metrics.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  createUpDownCounterWithDefault,
12
12
  } from '@aztec/telemetry-client';
13
13
 
14
- import type { BlockProposalValidationFailureReason } from './block_proposal_handler.js';
14
+ import type { BlockProposalValidationFailureReason } from './proposal_handler.js';
15
15
 
16
16
  export class ValidatorMetrics {
17
17
  private failedReexecutionCounter: UpDownCounter;
@@ -1,20 +1,29 @@
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';
4
7
  import { pick } from '@aztec/foundation/collection';
5
8
  import { Fr } from '@aztec/foundation/curves/bn254';
6
9
  import { TimeoutError } from '@aztec/foundation/error';
10
+ import type { LogData } from '@aztec/foundation/log';
7
11
  import { createLogger } from '@aztec/foundation/log';
8
12
  import { retryUntil } from '@aztec/foundation/retry';
9
13
  import { DateProvider, Timer } from '@aztec/foundation/timer';
10
14
  import type { P2P, PeerId } from '@aztec/p2p';
11
15
  import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
12
16
  import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
17
+ import { validateCheckpoint } from '@aztec/stdlib/checkpoint';
13
18
  import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
14
19
  import { Gas } from '@aztec/stdlib/gas';
15
20
  import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
16
- import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
17
- 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';
18
27
  import { MerkleTreeId } from '@aztec/stdlib/trees';
19
28
  import type { CheckpointGlobalVariables, FailedTx, Tx } from '@aztec/stdlib/tx';
20
29
  import {
@@ -66,11 +75,14 @@ export type BlockProposalValidationFailureResult = {
66
75
 
67
76
  export type BlockProposalValidationResult = BlockProposalValidationSuccessResult | BlockProposalValidationFailureResult;
68
77
 
78
+ export type CheckpointProposalValidationResult = { isValid: true } | { isValid: false; reason: string };
79
+
69
80
  type CheckpointComputationResult =
70
81
  | { checkpointNumber: CheckpointNumber; reason?: undefined }
71
82
  | { checkpointNumber?: undefined; reason: 'invalid_proposal' | 'global_variables_mismatch' };
72
83
 
73
- export class BlockProposalHandler {
84
+ /** Handles block and checkpoint proposals for both validator and non-validator nodes. */
85
+ export class ProposalHandler {
74
86
  public readonly tracer: Tracer;
75
87
 
76
88
  constructor(
@@ -82,21 +94,26 @@ export class BlockProposalHandler {
82
94
  private blockProposalValidator: BlockProposalValidator,
83
95
  private epochCache: EpochCache,
84
96
  private config: ValidatorClientFullConfig,
97
+ private blobClient: BlobClientInterface,
85
98
  private metrics?: ValidatorMetrics,
86
99
  private dateProvider: DateProvider = new DateProvider(),
87
100
  telemetry: TelemetryClient = getTelemetryClient(),
88
- private log = createLogger('validator:block-proposal-handler'),
101
+ private log = createLogger('validator:proposal-handler'),
89
102
  ) {
90
103
  if (config.fishermanMode) {
91
104
  this.log = this.log.createChild('[FISHERMAN]');
92
105
  }
93
- this.tracer = telemetry.getTracer('BlockProposalHandler');
106
+ this.tracer = telemetry.getTracer('ProposalHandler');
94
107
  }
95
108
 
96
- register(p2pClient: P2P, shouldReexecute: boolean): BlockProposalHandler {
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 {
97
114
  // Non-validator handler that processes or re-executes for monitoring but does not attest.
98
115
  // Returns boolean indicating whether the proposal was valid.
99
- const handler = async (proposal: BlockProposal, proposalSender: PeerId): Promise<boolean> => {
116
+ const blockHandler = async (proposal: BlockProposal, proposalSender: PeerId): Promise<boolean> => {
100
117
  try {
101
118
  const { slotNumber, blockNumber } = proposal;
102
119
  const result = await this.handleBlockProposal(proposal, proposalSender, shouldReexecute);
@@ -123,7 +140,35 @@ export class BlockProposalHandler {
123
140
  }
124
141
  };
125
142
 
126
- 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
+
127
172
  return this;
128
173
  }
129
174
 
@@ -166,15 +211,11 @@ export class BlockProposalHandler {
166
211
  // since a pending checkpoint prune may remove blocks we'd otherwise find.
167
212
  // This affects mostly the block_number_already_exists check, since a pending
168
213
  // checkpoint prune could remove a block that would conflict with this proposal.
169
- // When pipelining is enabled, the proposer builds ahead of L1 submission, so the
170
- // block source won't have synced to the proposed slot yet. Skip the sync wait to
171
- // avoid eating into the attestation window.
172
- if (!this.epochCache.isProposerPipeliningEnabled()) {
173
- const blockSourceSync = await this.waitForBlockSourceSync(slotNumber);
174
- if (!blockSourceSync) {
175
- this.log.warn(`Block source is not synced, skipping processing`, proposalInfo);
176
- return { isValid: false, reason: 'block_source_not_synced' };
177
- }
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' };
178
219
  }
179
220
 
180
221
  // Check that the parent proposal is a block we know, otherwise reexecution would fail.
@@ -629,4 +670,234 @@ export class BlockProposalHandler {
629
670
  totalManaUsed,
630
671
  };
631
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
+ }
632
903
  }