@aztec/validator-client 0.0.1-commit.dbf9cec → 0.0.1-commit.df81a97b5

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/factory.ts CHANGED
@@ -7,6 +7,7 @@ import type { L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
7
7
  import type { ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
8
8
  import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
9
9
  import type { TelemetryClient } from '@aztec/telemetry-client';
10
+ import type { SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
10
11
 
11
12
  import { BlockProposalHandler } from './block_proposal_handler.js';
12
13
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
@@ -29,6 +30,7 @@ export function createBlockProposalHandler(
29
30
  const metrics = new ValidatorMetrics(deps.telemetry);
30
31
  const blockProposalValidator = new BlockProposalValidator(deps.epochCache, {
31
32
  txsPermitted: !config.disableTransactions,
33
+ maxTxsPerBlock: config.validateMaxTxsPerBlock ?? config.validateMaxTxsPerCheckpoint,
32
34
  });
33
35
  return new BlockProposalHandler(
34
36
  deps.checkpointsBuilder,
@@ -58,6 +60,7 @@ export function createValidatorClient(
58
60
  epochCache: EpochCache;
59
61
  keyStoreManager: KeystoreManager | undefined;
60
62
  blobClient: BlobClientInterface;
63
+ slashingProtectionDb?: SlashingProtectionDatabase;
61
64
  },
62
65
  ) {
63
66
  if (config.disableValidator || !deps.keyStoreManager) {
@@ -78,5 +81,6 @@ export function createValidatorClient(
78
81
  deps.blobClient,
79
82
  deps.dateProvider,
80
83
  deps.telemetry,
84
+ deps.slashingProtectionDb,
81
85
  );
82
86
  }
@@ -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/metrics.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { EpochNumber } from '@aztec/foundation/branded-types';
2
+ import type { EthAddress } from '@aztec/foundation/eth-address';
1
3
  import type { BlockProposal } from '@aztec/stdlib/p2p';
2
4
  import {
3
5
  Attributes,
@@ -16,6 +18,8 @@ export class ValidatorMetrics {
16
18
  private successfulAttestationsCount: UpDownCounter;
17
19
  private failedAttestationsBadProposalCount: UpDownCounter;
18
20
  private failedAttestationsNodeIssueCount: UpDownCounter;
21
+ private currentEpoch: Gauge;
22
+ private attestedEpochCount: UpDownCounter;
19
23
 
20
24
  private reexMana: Histogram;
21
25
  private reexTx: Histogram;
@@ -64,6 +68,10 @@ export class ValidatorMetrics {
64
68
  },
65
69
  );
66
70
 
71
+ this.currentEpoch = meter.createGauge(Metrics.VALIDATOR_CURRENT_EPOCH);
72
+
73
+ this.attestedEpochCount = createUpDownCounterWithDefault(meter, Metrics.VALIDATOR_ATTESTED_EPOCH_COUNT);
74
+
67
75
  this.reexMana = meter.createHistogram(Metrics.VALIDATOR_RE_EXECUTION_MANA);
68
76
 
69
77
  this.reexTx = meter.createHistogram(Metrics.VALIDATOR_RE_EXECUTION_TX_COUNT);
@@ -110,4 +118,14 @@ export class ValidatorMetrics {
110
118
  [Attributes.IS_COMMITTEE_MEMBER]: inCommittee,
111
119
  });
112
120
  }
121
+
122
+ /** Update the gauge tracking the current epoch number (proxy for total epochs elapsed). */
123
+ public setCurrentEpoch(epoch: EpochNumber) {
124
+ this.currentEpoch.record(Number(epoch));
125
+ }
126
+
127
+ /** Increment the count of epochs in which the given attester submitted at least one attestation. */
128
+ public incAttestedEpochCount(attester: EthAddress) {
129
+ this.attestedEpochCount.add(1, { [Attributes.ATTESTER_ADDRESS]: attester.toString() });
130
+ }
113
131
  }
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,8 +46,12 @@ 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 { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
49
+ import {
50
+ createHASigner,
51
+ createLocalSignerWithProtection,
52
+ createSignerFromSharedDb,
53
+ } from '@aztec/validator-ha-signer/factory';
54
+ import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
50
55
  import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
51
56
 
52
57
  import { EventEmitter } from 'events';
@@ -89,6 +94,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
89
94
 
90
95
  private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
91
96
  private epochCacheUpdateLoop: RunningPromise;
97
+ /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
98
+ private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
92
99
 
93
100
  private proposersOfInvalidBlocks: Set<string> = new Set();
94
101
 
@@ -106,7 +113,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
106
113
  private l1ToL2MessageSource: L1ToL2MessageSource,
107
114
  private config: ValidatorClientFullConfig,
108
115
  private blobClient: BlobClientInterface,
109
- private haSigner: ValidatorHASigner | undefined,
116
+ private slashingProtectionSigner: ValidatorHASigner,
110
117
  private dateProvider: DateProvider = new DateProvider(),
111
118
  telemetry: TelemetryClient = getTelemetryClient(),
112
119
  log = createLogger('validator'),
@@ -160,6 +167,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
160
167
  this.log.trace(`No committee found for slot`);
161
168
  return;
162
169
  }
170
+ this.metrics.setCurrentEpoch(epoch);
163
171
  if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
164
172
  const me = this.getValidatorAddresses();
165
173
  const committeeSet = new Set(committee.map(v => v.toString()));
@@ -193,10 +201,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
193
201
  blobClient: BlobClientInterface,
194
202
  dateProvider: DateProvider = new DateProvider(),
195
203
  telemetry: TelemetryClient = getTelemetryClient(),
204
+ slashingProtectionDb?: SlashingProtectionDatabase,
196
205
  ) {
197
206
  const metrics = new ValidatorMetrics(telemetry);
198
207
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
199
208
  txsPermitted: !config.disableTransactions,
209
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
200
210
  });
201
211
  const blockProposalHandler = new BlockProposalHandler(
202
212
  checkpointsBuilder,
@@ -213,18 +223,33 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
213
223
  );
214
224
 
215
225
  const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
216
- let validatorKeyStore: ExtendedValidatorKeyStore = nodeKeystoreAdapter;
217
- let haSigner: ValidatorHASigner | undefined;
218
- if (config.haSigningEnabled) {
226
+ let slashingProtectionSigner: ValidatorHASigner;
227
+ if (slashingProtectionDb) {
228
+ // Shared database mode: use a pre-existing database (e.g. for testing HA setups).
229
+ ({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
230
+ telemetryClient: telemetry,
231
+ dateProvider,
232
+ }));
233
+ } else if (config.haSigningEnabled) {
234
+ // Multi-node HA mode: use PostgreSQL-backed distributed locking.
219
235
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
220
236
  const haConfig = {
221
237
  ...config,
222
238
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
223
239
  };
224
- const { signer } = await createHASigner(haConfig, { telemetryClient: telemetry, dateProvider });
225
- haSigner = signer;
226
- validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
240
+ ({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
241
+ telemetryClient: telemetry,
242
+ dateProvider,
243
+ }));
244
+ } else {
245
+ // Single-node mode: use LMDB-backed local signing protection.
246
+ // This prevents double-signing if the node crashes and restarts mid-proposal.
247
+ ({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
248
+ telemetryClient: telemetry,
249
+ dateProvider,
250
+ }));
227
251
  }
252
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
228
253
 
229
254
  const validator = new ValidatorClient(
230
255
  validatorKeyStore,
@@ -237,7 +262,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
237
262
  l1ToL2MessageSource,
238
263
  config,
239
264
  blobClient,
240
- haSigner,
265
+ slashingProtectionSigner,
241
266
  dateProvider,
242
267
  telemetry,
243
268
  );
@@ -276,24 +301,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
276
301
  }
277
302
 
278
303
  public reloadKeystore(newManager: KeystoreManager): void {
279
- if (this.config.haSigningEnabled && !this.haSigner) {
280
- this.log.warn(
281
- 'HA signing is enabled in config but was not initialized at startup. ' +
282
- 'Restart the node to enable HA signing.',
283
- );
284
- } else if (!this.config.haSigningEnabled && this.haSigner) {
285
- this.log.warn(
286
- 'HA signing was disabled via config update but the HA signer is still active. ' +
287
- 'Restart the node to fully disable HA signing.',
288
- );
289
- }
290
-
291
304
  const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
292
- if (this.haSigner) {
293
- this.keyStore = new HAKeyStore(newAdapter, this.haSigner);
294
- } else {
295
- this.keyStore = newAdapter;
296
- }
305
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
297
306
  this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
298
307
  }
299
308
 
@@ -380,13 +389,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
380
389
  return false;
381
390
  }
382
391
 
383
- // Ignore proposals from ourselves (may happen in HA setups)
392
+ // Log self-proposals from HA peers (same validator key on different nodes)
384
393
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
385
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
394
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
386
395
  proposer: proposer.toString(),
387
396
  slotNumber,
388
397
  });
389
- return false;
390
398
  }
391
399
 
392
400
  // Check if we're in the committee (for metrics purposes)
@@ -418,9 +426,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
418
426
  );
419
427
 
420
428
  if (!validationResult.isValid) {
421
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
422
-
423
429
  const reason = validationResult.reason || 'unknown';
430
+
431
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
432
+
424
433
  // Classify failure reason: bad proposal vs node issue
425
434
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
426
435
  'invalid_proposal',
@@ -475,26 +484,26 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
475
484
  proposal: CheckpointProposalCore,
476
485
  _proposalSender: PeerId,
477
486
  ): Promise<CheckpointAttestation[] | undefined> {
478
- const slotNumber = proposal.slotNumber;
487
+ const proposalSlotNumber = proposal.slotNumber;
479
488
  const proposer = proposal.getSender();
480
489
 
481
490
  // If escape hatch is open for this slot's epoch, do not attest.
482
- if (await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber)) {
483
- this.log.warn(`Escape hatch open for slot ${slotNumber}, skipping checkpoint attestation handling`);
491
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
492
+ this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
484
493
  return undefined;
485
494
  }
486
495
 
487
496
  // Reject proposals with invalid signatures
488
497
  if (!proposer) {
489
- this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
498
+ this.log.warn(`Received checkpoint proposal with invalid signature for proposal slot ${proposalSlotNumber}`);
490
499
  return undefined;
491
500
  }
492
501
 
493
502
  // Ignore proposals from ourselves (may happen in HA setups)
494
503
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
495
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
504
+ this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
496
505
  proposer: proposer.toString(),
497
- slotNumber,
506
+ proposalSlotNumber,
498
507
  });
499
508
  return undefined;
500
509
  }
@@ -502,30 +511,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
502
511
  // Validate fee asset price modifier is within allowed range
503
512
  if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
504
513
  this.log.warn(
505
- `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`,
514
+ `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${proposalSlotNumber}`,
506
515
  );
507
516
  return undefined;
508
517
  }
509
518
 
510
- // Check that I have any address in current committee before attesting
511
- const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
519
+ // Check that I have any address in the committee where this checkpoint will land before attesting
520
+ const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
512
521
  const partOfCommittee = inCommittee.length > 0;
513
522
 
514
523
  const proposalInfo = {
515
- slotNumber,
524
+ proposalSlotNumber,
516
525
  archive: proposal.archive.toString(),
517
526
  proposer: proposer.toString(),
518
- txCount: proposal.txHashes.length,
519
527
  };
520
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
528
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
521
529
  ...proposalInfo,
522
- txHashes: proposal.txHashes.map(t => t.toString()),
523
530
  fishermanMode: this.config.fishermanMode || false,
524
531
  });
525
532
 
526
533
  // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
527
534
  if (this.config.skipCheckpointProposalValidation) {
528
- this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
535
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
529
536
  } else {
530
537
  const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
531
538
  if (!validationResult.isValid) {
@@ -547,14 +554,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
547
554
  }
548
555
 
549
556
  // Provided all of the above checks pass, we can attest to the proposal
550
- this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, {
551
- ...proposalInfo,
552
- inCommittee: partOfCommittee,
553
- fishermanMode: this.config.fishermanMode || false,
554
- });
557
+ this.log.info(
558
+ `${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
559
+ {
560
+ ...proposalInfo,
561
+ inCommittee: partOfCommittee,
562
+ fishermanMode: this.config.fishermanMode || false,
563
+ },
564
+ );
555
565
 
556
566
  this.metrics.incSuccessfulAttestations(inCommittee.length);
557
567
 
568
+ // Track epoch participation per attester: count each (attester, epoch) pair at most once
569
+ const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
570
+ for (const attester of inCommittee) {
571
+ const key = attester.toString();
572
+ const lastEpoch = this.lastAttestedEpochByAttester.get(key);
573
+ if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
574
+ this.lastAttestedEpochByAttester.set(key, proposalEpoch);
575
+ this.metrics.incAttestedEpochCount(attester);
576
+ }
577
+ }
578
+
558
579
  // Determine which validators should attest
559
580
  let attestors: EthAddress[];
560
581
  if (partOfCommittee) {
@@ -573,7 +594,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
573
594
 
574
595
  if (this.config.fishermanMode) {
575
596
  // bail out early and don't save attestations to the pool in fisherman mode
576
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
597
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
577
598
  ...proposalInfo,
578
599
  attestors: attestors.map(a => a.toString()),
579
600
  });
@@ -751,6 +772,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
751
772
  return { isValid: false, reason: 'out_hash_mismatch' };
752
773
  }
753
774
 
775
+ // Final round of validations on the checkpoint, just in case.
776
+ try {
777
+ validateCheckpoint(computedCheckpoint, {
778
+ rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit,
779
+ maxDABlockGas: this.config.validateMaxDABlockGas,
780
+ maxL2BlockGas: this.config.validateMaxL2BlockGas,
781
+ maxTxsPerBlock: this.config.validateMaxTxsPerBlock,
782
+ maxTxsPerCheckpoint: this.config.validateMaxTxsPerCheckpoint,
783
+ });
784
+ } catch (err) {
785
+ this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo);
786
+ return { isValid: false, reason: 'checkpoint_validation_failed' };
787
+ }
788
+
754
789
  this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
755
790
  return { isValid: true };
756
791
  } finally {