@aztec/validator-client 0.0.1-commit.181e2d196 → 0.0.1-commit.1a421b1a1

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.
@@ -1,5 +1,7 @@
1
+ import { NUM_CHECKPOINT_END_MARKER_FIELDS, getNumBlockEndBlobFields } from '@aztec/blob-lib/encoding';
2
+ import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB, MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants';
1
3
  import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types';
2
- import { merge, pick } from '@aztec/foundation/collection';
4
+ import { merge, pick, sum } from '@aztec/foundation/collection';
3
5
  import { Fr } from '@aztec/foundation/curves/bn254';
4
6
  import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log';
5
7
  import { bufferToHex } from '@aztec/foundation/string';
@@ -65,6 +67,7 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder {
65
67
 
66
68
  /**
67
69
  * Builds a single block within this checkpoint.
70
+ * Automatically caps gas and blob field limits based on checkpoint-level budgets and prior blocks.
68
71
  */
69
72
  async buildBlock(
70
73
  pendingTxs: Iterable<Tx> | AsyncIterable<Tx>,
@@ -94,8 +97,14 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder {
94
97
  });
95
98
  const { processor, validator } = await this.makeBlockBuilderDeps(globalVariables, this.fork);
96
99
 
97
- const [publicProcessorDuration, [processedTxs, failedTxs, usedTxs, _, usedTxBlobFields]] = await elapsed(() =>
98
- processor.process(pendingTxs, opts, validator),
100
+ // Cap gas limits amd available blob fields by remaining checkpoint-level budgets
101
+ const cappedOpts: PublicProcessorLimits & { expectedEndState?: StateReference } = {
102
+ ...opts,
103
+ ...this.capLimitsByCheckpointBudgets(opts),
104
+ };
105
+
106
+ const [publicProcessorDuration, [processedTxs, failedTxs, usedTxs]] = await elapsed(() =>
107
+ processor.process(pendingTxs, cappedOpts, validator),
99
108
  );
100
109
 
101
110
  // Throw if we didn't collect a single valid tx and we're not allowed to build empty blocks
@@ -109,9 +118,6 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder {
109
118
  expectedEndState: opts.expectedEndState,
110
119
  });
111
120
 
112
- // How much public gas was processed
113
- const publicGas = processedTxs.reduce((acc, tx) => acc.add(tx.gasUsed.publicGas), Gas.empty());
114
-
115
121
  this.log.debug('Built block within checkpoint', {
116
122
  header: block.header.toInspect(),
117
123
  processedTxs: processedTxs.map(tx => tx.hash.toString()),
@@ -120,12 +126,10 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder {
120
126
 
121
127
  return {
122
128
  block,
123
- publicGas,
124
129
  publicProcessorDuration,
125
130
  numTxs: processedTxs.length,
126
131
  failedTxs,
127
132
  usedTxs,
128
- usedTxBlobFields,
129
133
  };
130
134
  }
131
135
 
@@ -147,6 +151,61 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder {
147
151
  return this.checkpointBuilder.clone().completeCheckpoint();
148
152
  }
149
153
 
154
+ /**
155
+ * Caps per-block gas and blob field limits by remaining checkpoint-level budgets.
156
+ * Computes remaining L2 gas (mana), DA gas, and blob fields from blocks already added to the checkpoint,
157
+ * then returns opts with maxBlockGas and maxBlobFields capped accordingly.
158
+ */
159
+ protected capLimitsByCheckpointBudgets(
160
+ opts: PublicProcessorLimits,
161
+ ): Pick<PublicProcessorLimits, 'maxBlockGas' | 'maxBlobFields' | 'maxTransactions'> {
162
+ const existingBlocks = this.checkpointBuilder.getBlocks();
163
+
164
+ // Remaining L2 gas (mana)
165
+ // IMPORTANT: This assumes mana is computed solely based on L2 gas used in transactions.
166
+ // This may change in the future.
167
+ const usedMana = sum(existingBlocks.map(b => b.header.totalManaUsed.toNumber()));
168
+ const remainingMana = this.config.rollupManaLimit - usedMana;
169
+
170
+ // Remaining DA gas
171
+ const usedDAGas = sum(existingBlocks.map(b => b.computeDAGasUsed())) ?? 0;
172
+ const remainingDAGas = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT - usedDAGas;
173
+
174
+ // Remaining blob fields (block blob fields include both tx data and block-end overhead)
175
+ const usedBlobFields = sum(existingBlocks.map(b => b.toBlobFields().length));
176
+ const totalBlobCapacity = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS;
177
+ const isFirstBlock = existingBlocks.length === 0;
178
+ const blockEndOverhead = getNumBlockEndBlobFields(isFirstBlock);
179
+ const maxBlobFieldsForTxs = totalBlobCapacity - usedBlobFields - blockEndOverhead;
180
+
181
+ // Cap L2 gas by remaining checkpoint mana
182
+ const cappedL2Gas = Math.min(opts.maxBlockGas?.l2Gas ?? remainingMana, remainingMana);
183
+
184
+ // Cap DA gas by remaining checkpoint DA gas budget
185
+ const cappedDAGas = Math.min(opts.maxBlockGas?.daGas ?? remainingDAGas, remainingDAGas);
186
+
187
+ // Cap blob fields by remaining checkpoint blob capacity
188
+ const cappedBlobFields =
189
+ opts.maxBlobFields !== undefined ? Math.min(opts.maxBlobFields, maxBlobFieldsForTxs) : maxBlobFieldsForTxs;
190
+
191
+ // Cap transaction count by remaining checkpoint tx budget
192
+ let cappedMaxTransactions: number | undefined;
193
+ if (this.config.maxTxsPerCheckpoint !== undefined) {
194
+ const usedTxs = sum(existingBlocks.map(b => b.body.txEffects.length));
195
+ const remainingTxs = Math.max(0, this.config.maxTxsPerCheckpoint - usedTxs);
196
+ cappedMaxTransactions =
197
+ opts.maxTransactions !== undefined ? Math.min(opts.maxTransactions, remainingTxs) : remainingTxs;
198
+ } else {
199
+ cappedMaxTransactions = opts.maxTransactions;
200
+ }
201
+
202
+ return {
203
+ maxBlockGas: new Gas(cappedDAGas, cappedL2Gas),
204
+ maxBlobFields: cappedBlobFields,
205
+ maxTransactions: cappedMaxTransactions,
206
+ };
207
+ }
208
+
150
209
  protected async makeBlockBuilderDeps(globalVariables: GlobalVariables, fork: MerkleTreeWriteOperations) {
151
210
  const txPublicSetupAllowList = [
152
211
  ...(await getDefaultAllowedSetupFunctions()),
package/src/config.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  secretValueConfigHelper,
7
7
  } from '@aztec/foundation/config';
8
8
  import { EthAddress } from '@aztec/foundation/eth-address';
9
- import { validatorHASignerConfigMappings } from '@aztec/stdlib/ha-signing';
9
+ import { localSignerConfigMappings, validatorHASignerConfigMappings } from '@aztec/stdlib/ha-signing';
10
10
  import type { ValidatorClientConfig } from '@aztec/stdlib/interfaces/server';
11
11
 
12
12
  export type { ValidatorClientConfig };
@@ -77,6 +77,27 @@ export const validatorClientConfigMappings: ConfigMappingsType<ValidatorClientCo
77
77
  description: 'Agree to attest to equivocated checkpoint proposals (for testing purposes only)',
78
78
  ...booleanConfigHelper(false),
79
79
  },
80
+ validateMaxL2BlockGas: {
81
+ env: 'VALIDATOR_MAX_L2_BLOCK_GAS',
82
+ description: 'Maximum L2 block gas for validation. Proposals exceeding this limit are rejected.',
83
+ parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined),
84
+ },
85
+ validateMaxDABlockGas: {
86
+ env: 'VALIDATOR_MAX_DA_BLOCK_GAS',
87
+ description: 'Maximum DA block gas for validation. Proposals exceeding this limit are rejected.',
88
+ parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined),
89
+ },
90
+ validateMaxTxsPerBlock: {
91
+ env: 'VALIDATOR_MAX_TX_PER_BLOCK',
92
+ description: 'Maximum transactions per block for validation. Proposals exceeding this limit are rejected.',
93
+ parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined),
94
+ },
95
+ validateMaxTxsPerCheckpoint: {
96
+ env: 'VALIDATOR_MAX_TX_PER_CHECKPOINT',
97
+ description: 'Maximum transactions per checkpoint for validation. Proposals exceeding this limit are rejected.',
98
+ parseEnv: (val: string) => (val ? parseInt(val, 10) : undefined),
99
+ },
100
+ ...localSignerConfigMappings,
80
101
  ...validatorHASignerConfigMappings,
81
102
  };
82
103
 
@@ -150,16 +150,10 @@ export class ValidationService {
150
150
  );
151
151
 
152
152
  // TODO(spy/ha): Use checkpointNumber instead of blockNumber once CheckpointHeader includes it.
153
- // Currently using lastBlock.blockNumber as a proxy for checkpoint identification in HA signing.
153
+ // CheckpointProposalCore doesn't have lastBlock info, so use 0 as a proxy.
154
154
  // blockNumber is NOT used for the primary key so it's safe to use here.
155
155
  // See CheckpointHeader TODO and SigningContext types documentation.
156
- let blockNumber: BlockNumber;
157
- try {
158
- blockNumber = proposal.blockNumber;
159
- } catch {
160
- // Checkpoint proposal may not have lastBlock, use 0 as fallback
161
- blockNumber = BlockNumber(0);
162
- }
156
+ const blockNumber = BlockNumber(0);
163
157
  const context: SigningContext = {
164
158
  slot: proposal.slotNumber,
165
159
  blockNumber,
package/src/factory.ts CHANGED
@@ -29,7 +29,7 @@ export function createBlockProposalHandler(
29
29
  const metrics = new ValidatorMetrics(deps.telemetry);
30
30
  const blockProposalValidator = new BlockProposalValidator(deps.epochCache, {
31
31
  txsPermitted: !config.disableTransactions,
32
- maxTxsPerBlock: config.maxTxsPerBlock,
32
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
33
33
  });
34
34
  return new BlockProposalHandler(
35
35
  deps.checkpointsBuilder,
@@ -240,7 +240,7 @@ export class HAKeyStore implements ExtendedValidatorKeyStore {
240
240
  }
241
241
 
242
242
  if (error instanceof SlashingProtectionError) {
243
- this.log.warn(`Duty already signed by another node with different payload`, {
243
+ this.log.info(`Duty already signed by another node with different payload`, {
244
244
  dutyType: context.dutyType,
245
245
  slot: context.slot,
246
246
  existingMessageHash: error.existingMessageHash,
package/src/validator.ts CHANGED
@@ -24,6 +24,7 @@ import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol }
24
24
  import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
25
25
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
26
26
  import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
27
+ import { validateCheckpoint } from '@aztec/stdlib/checkpoint';
27
28
  import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
28
29
  import type {
29
30
  CreateCheckpointProposalLastBlockData,
@@ -45,7 +46,7 @@ import type { CheckpointHeader } from '@aztec/stdlib/rollup';
45
46
  import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
46
47
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
47
48
  import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
48
- import { createHASigner } from '@aztec/validator-ha-signer/factory';
49
+ import { createHASigner, createLocalSignerWithProtection } from '@aztec/validator-ha-signer/factory';
49
50
  import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
50
51
  import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
51
52
 
@@ -108,7 +109,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
108
109
  private l1ToL2MessageSource: L1ToL2MessageSource,
109
110
  private config: ValidatorClientFullConfig,
110
111
  private blobClient: BlobClientInterface,
111
- private haSigner: ValidatorHASigner | undefined,
112
+ private slashingProtectionSigner: ValidatorHASigner,
112
113
  private dateProvider: DateProvider = new DateProvider(),
113
114
  telemetry: TelemetryClient = getTelemetryClient(),
114
115
  log = createLogger('validator'),
@@ -200,7 +201,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
200
201
  const metrics = new ValidatorMetrics(telemetry);
201
202
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
202
203
  txsPermitted: !config.disableTransactions,
203
- maxTxsPerBlock: config.maxTxsPerBlock,
204
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
204
205
  });
205
206
  const blockProposalHandler = new BlockProposalHandler(
206
207
  checkpointsBuilder,
@@ -217,18 +218,27 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
217
218
  );
218
219
 
219
220
  const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
220
- let validatorKeyStore: ExtendedValidatorKeyStore = nodeKeystoreAdapter;
221
- let haSigner: ValidatorHASigner | undefined;
221
+ let slashingProtectionSigner: ValidatorHASigner;
222
222
  if (config.haSigningEnabled) {
223
+ // Multi-node HA mode: use PostgreSQL-backed distributed locking.
223
224
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
224
225
  const haConfig = {
225
226
  ...config,
226
227
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
227
228
  };
228
- const { signer } = await createHASigner(haConfig, { telemetryClient: telemetry, dateProvider });
229
- haSigner = signer;
230
- validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
229
+ ({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
230
+ telemetryClient: telemetry,
231
+ dateProvider,
232
+ }));
233
+ } else {
234
+ // Single-node mode: use LMDB-backed local signing protection.
235
+ // This prevents double-signing if the node crashes and restarts mid-proposal.
236
+ ({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
237
+ telemetryClient: telemetry,
238
+ dateProvider,
239
+ }));
231
240
  }
241
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
232
242
 
233
243
  const validator = new ValidatorClient(
234
244
  validatorKeyStore,
@@ -241,7 +251,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
241
251
  l1ToL2MessageSource,
242
252
  config,
243
253
  blobClient,
244
- haSigner,
254
+ slashingProtectionSigner,
245
255
  dateProvider,
246
256
  telemetry,
247
257
  );
@@ -280,24 +290,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
280
290
  }
281
291
 
282
292
  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
293
  const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
296
- if (this.haSigner) {
297
- this.keyStore = new HAKeyStore(newAdapter, this.haSigner);
298
- } else {
299
- this.keyStore = newAdapter;
300
- }
294
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
301
295
  this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
302
296
  }
303
297
 
@@ -386,7 +380,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
386
380
 
387
381
  // Ignore proposals from ourselves (may happen in HA setups)
388
382
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
389
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
383
+ this.log.debug(`Ignoring block proposal from self for slot ${slotNumber}`, {
390
384
  proposer: proposer.toString(),
391
385
  slotNumber,
392
386
  });
@@ -422,9 +416,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
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',
@@ -496,7 +491,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
496
491
 
497
492
  // Ignore proposals from ourselves (may happen in HA setups)
498
493
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
499
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
494
+ this.log.debug(`Ignoring block proposal from self for slot ${slotNumber}`, {
500
495
  proposer: proposer.toString(),
501
496
  slotNumber,
502
497
  });
@@ -519,11 +514,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
519
514
  slotNumber,
520
515
  archive: proposal.archive.toString(),
521
516
  proposer: proposer.toString(),
522
- txCount: proposal.txHashes.length,
523
517
  };
524
518
  this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
525
519
  ...proposalInfo,
526
- txHashes: proposal.txHashes.map(t => t.toString()),
527
520
  fishermanMode: this.config.fishermanMode || false,
528
521
  });
529
522
 
@@ -766,6 +759,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
766
759
  return { isValid: false, reason: 'out_hash_mismatch' };
767
760
  }
768
761
 
762
+ // Final round of validations on the checkpoint, just in case.
763
+ try {
764
+ validateCheckpoint(computedCheckpoint, {
765
+ rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit,
766
+ maxDABlockGas: this.config.validateMaxDABlockGas,
767
+ maxL2BlockGas: this.config.validateMaxL2BlockGas,
768
+ maxTxsPerBlock: this.config.validateMaxTxsPerBlock,
769
+ maxTxsPerCheckpoint: this.config.validateMaxTxsPerCheckpoint,
770
+ });
771
+ } catch (err) {
772
+ this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo);
773
+ return { isValid: false, reason: 'checkpoint_validation_failed' };
774
+ }
775
+
769
776
  this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
770
777
  return { isValid: true };
771
778
  } finally {