@aztec/validator-client 0.0.1-commit.e6bd8901 → 0.0.1-commit.ec7ac5448

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 (54) hide show
  1. package/README.md +60 -18
  2. package/dest/checkpoint_builder.d.ts +26 -14
  3. package/dest/checkpoint_builder.d.ts.map +1 -1
  4. package/dest/checkpoint_builder.js +136 -45
  5. package/dest/config.d.ts +1 -1
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +28 -9
  8. package/dest/duties/validation_service.d.ts +3 -4
  9. package/dest/duties/validation_service.d.ts.map +1 -1
  10. package/dest/duties/validation_service.js +11 -22
  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.d.ts +1 -1
  18. package/dest/key_store/ha_key_store.d.ts.map +1 -1
  19. package/dest/key_store/ha_key_store.js +3 -3
  20. package/dest/metrics.d.ts +12 -3
  21. package/dest/metrics.d.ts.map +1 -1
  22. package/dest/metrics.js +46 -5
  23. package/dest/proposal_handler.d.ts +107 -0
  24. package/dest/proposal_handler.d.ts.map +1 -0
  25. package/dest/proposal_handler.js +966 -0
  26. package/dest/validator.d.ts +41 -20
  27. package/dest/validator.d.ts.map +1 -1
  28. package/dest/validator.js +183 -199
  29. package/package.json +19 -17
  30. package/src/checkpoint_builder.ts +183 -50
  31. package/src/config.ts +28 -9
  32. package/src/duties/validation_service.ts +18 -24
  33. package/src/factory.ts +9 -3
  34. package/src/index.ts +1 -2
  35. package/src/key_store/ha_key_store.ts +3 -3
  36. package/src/metrics.ts +63 -6
  37. package/src/proposal_handler.ts +1033 -0
  38. package/src/validator.ts +248 -229
  39. package/dest/block_proposal_handler.d.ts +0 -64
  40. package/dest/block_proposal_handler.d.ts.map +0 -1
  41. package/dest/block_proposal_handler.js +0 -545
  42. package/dest/tx_validator/index.d.ts +0 -3
  43. package/dest/tx_validator/index.d.ts.map +0 -1
  44. package/dest/tx_validator/index.js +0 -2
  45. package/dest/tx_validator/nullifier_cache.d.ts +0 -14
  46. package/dest/tx_validator/nullifier_cache.d.ts.map +0 -1
  47. package/dest/tx_validator/nullifier_cache.js +0 -24
  48. package/dest/tx_validator/tx_validator_factory.d.ts +0 -18
  49. package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
  50. package/dest/tx_validator/tx_validator_factory.js +0 -54
  51. package/src/block_proposal_handler.ts +0 -554
  52. package/src/tx_validator/index.ts +0 -2
  53. package/src/tx_validator/nullifier_cache.ts +0 -30
  54. package/src/tx_validator/tx_validator_factory.ts +0 -135
package/src/validator.ts CHANGED
@@ -9,53 +9,56 @@ import {
9
9
  SlotNumber,
10
10
  } from '@aztec/foundation/branded-types';
11
11
  import { Fr } from '@aztec/foundation/curves/bn254';
12
- import { TimeoutError } from '@aztec/foundation/error';
13
12
  import type { EthAddress } from '@aztec/foundation/eth-address';
14
13
  import type { Signature } from '@aztec/foundation/eth-signature';
15
14
  import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
16
- import { retryUntil } from '@aztec/foundation/retry';
17
15
  import { RunningPromise } from '@aztec/foundation/running-promise';
18
16
  import { sleep } from '@aztec/foundation/sleep';
19
17
  import { DateProvider } from '@aztec/foundation/timer';
20
18
  import type { KeystoreManager } from '@aztec/node-keystore';
21
- import type { P2P, PeerId, TxProvider } from '@aztec/p2p';
19
+ import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
22
20
  import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
23
21
  import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
24
22
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
25
- import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
23
+ import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
26
24
  import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
27
25
  import type {
28
- CreateCheckpointProposalLastBlockData,
26
+ ITxProvider,
29
27
  Validator,
30
28
  ValidatorClientFullConfig,
31
29
  WorldStateSynchronizer,
32
30
  } from '@aztec/stdlib/interfaces/server';
33
31
  import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
34
- import type {
35
- BlockProposal,
36
- BlockProposalOptions,
37
- CheckpointAttestation,
38
- CheckpointProposalCore,
39
- CheckpointProposalOptions,
32
+ import {
33
+ type BlockProposal,
34
+ type BlockProposalOptions,
35
+ type CheckpointAttestation,
36
+ CheckpointProposal,
37
+ type CheckpointProposalCore,
38
+ type CheckpointProposalOptions,
40
39
  } from '@aztec/stdlib/p2p';
41
- import { CheckpointProposal } from '@aztec/stdlib/p2p';
42
40
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
43
- import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
41
+ import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
44
42
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
45
43
  import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
46
- import { createHASigner } from '@aztec/validator-ha-signer/factory';
47
- import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
44
+ import {
45
+ createHASigner,
46
+ createLocalSignerWithProtection,
47
+ createSignerFromSharedDb,
48
+ } from '@aztec/validator-ha-signer/factory';
49
+ import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
50
+ import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
48
51
 
49
52
  import { EventEmitter } from 'events';
50
53
  import type { TypedDataDefinition } from 'viem';
51
54
 
52
- import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
53
55
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
54
56
  import { ValidationService } from './duties/validation_service.js';
55
57
  import { HAKeyStore } from './key_store/ha_key_store.js';
56
58
  import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
57
59
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
58
60
  import { ValidatorMetrics } from './metrics.js';
61
+ import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.js';
59
62
 
60
63
  // We maintain a set of proposers who have proposed invalid blocks.
61
64
  // Just cap the set to avoid unbounded growth.
@@ -75,34 +78,37 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
75
78
  private validationService: ValidationService;
76
79
  private metrics: ValidatorMetrics;
77
80
  private log: Logger;
78
-
79
81
  // Whether it has already registered handlers on the p2p client
80
82
  private hasRegisteredHandlers = false;
81
83
 
82
- // Used to check if we are sending the same proposal twice
83
- private previousProposal?: BlockProposal;
84
+ /** Tracks the last block proposal we created, to detect duplicate proposal attempts. */
85
+ private lastProposedBlock?: BlockProposal;
86
+
87
+ /** Tracks the last checkpoint proposal we created. */
88
+ private lastProposedCheckpoint?: CheckpointProposal;
84
89
 
85
90
  private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
86
91
  private epochCacheUpdateLoop: RunningPromise;
92
+ /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
93
+ private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
87
94
 
88
95
  private proposersOfInvalidBlocks: Set<string> = new Set();
89
96
 
90
- // TODO(palla/mbps): Remove this once checkpoint validation is stable and we can validate all blocks properly.
91
- // Tracks slots for which we have successfully validated a block proposal, so we can attest to checkpoint proposals for those slots.
92
- // eslint-disable-next-line aztec-custom/no-non-primitive-in-collections
93
- private validatedBlockSlots: Set<SlotNumber> = new Set();
97
+ /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
98
+ private lastAttestedProposal?: CheckpointProposalCore;
94
99
 
95
100
  protected constructor(
96
101
  private keyStore: ExtendedValidatorKeyStore,
97
102
  private epochCache: EpochCache,
98
103
  private p2pClient: P2P,
99
- private blockProposalHandler: BlockProposalHandler,
104
+ private proposalHandler: ProposalHandler,
100
105
  private blockSource: L2BlockSource,
101
106
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
102
107
  private worldState: WorldStateSynchronizer,
103
108
  private l1ToL2MessageSource: L1ToL2MessageSource,
104
109
  private config: ValidatorClientFullConfig,
105
110
  private blobClient: BlobClientInterface,
111
+ private slashingProtectionSigner: ValidatorHASigner,
106
112
  private dateProvider: DateProvider = new DateProvider(),
107
113
  telemetry: TelemetryClient = getTelemetryClient(),
108
114
  log = createLogger('validator'),
@@ -156,6 +162,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
156
162
  this.log.trace(`No committee found for slot`);
157
163
  return;
158
164
  }
165
+ this.metrics.setCurrentEpoch(epoch);
159
166
  if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
160
167
  const me = this.getValidatorAddresses();
161
168
  const committeeSet = new Set(committee.map(v => v.toString()));
@@ -184,17 +191,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
184
191
  p2pClient: P2P,
185
192
  blockSource: L2BlockSource & L2BlockSink,
186
193
  l1ToL2MessageSource: L1ToL2MessageSource,
187
- txProvider: TxProvider,
194
+ txProvider: ITxProvider,
188
195
  keyStoreManager: KeystoreManager,
189
196
  blobClient: BlobClientInterface,
190
197
  dateProvider: DateProvider = new DateProvider(),
191
198
  telemetry: TelemetryClient = getTelemetryClient(),
199
+ slashingProtectionDb?: SlashingProtectionDatabase,
192
200
  ) {
193
201
  const metrics = new ValidatorMetrics(telemetry);
194
202
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
195
203
  txsPermitted: !config.disableTransactions,
204
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
196
205
  });
197
- const blockProposalHandler = new BlockProposalHandler(
206
+ const proposalHandler = new ProposalHandler(
198
207
  checkpointsBuilder,
199
208
  worldState,
200
209
  blockSource,
@@ -203,33 +212,53 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
203
212
  blockProposalValidator,
204
213
  epochCache,
205
214
  config,
215
+ blobClient,
206
216
  metrics,
207
217
  dateProvider,
208
218
  telemetry,
209
219
  );
210
220
 
211
- let validatorKeyStore: ExtendedValidatorKeyStore = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
212
- if (config.haSigningEnabled) {
221
+ const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
222
+ let slashingProtectionSigner: ValidatorHASigner;
223
+ if (slashingProtectionDb) {
224
+ // Shared database mode: use a pre-existing database (e.g. for testing HA setups).
225
+ ({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
226
+ telemetryClient: telemetry,
227
+ dateProvider,
228
+ }));
229
+ } else if (config.haSigningEnabled) {
230
+ // Multi-node HA mode: use PostgreSQL-backed distributed locking.
213
231
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
214
232
  const haConfig = {
215
233
  ...config,
216
234
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
217
235
  };
218
- const { signer } = await createHASigner(haConfig);
219
- validatorKeyStore = new HAKeyStore(validatorKeyStore, signer);
236
+ ({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
237
+ telemetryClient: telemetry,
238
+ dateProvider,
239
+ }));
240
+ } else {
241
+ // Single-node mode: use LMDB-backed local signing protection.
242
+ // This prevents double-signing if the node crashes and restarts mid-proposal.
243
+ ({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
244
+ telemetryClient: telemetry,
245
+ dateProvider,
246
+ }));
220
247
  }
248
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
221
249
 
222
250
  const validator = new ValidatorClient(
223
251
  validatorKeyStore,
224
252
  epochCache,
225
253
  p2pClient,
226
- blockProposalHandler,
254
+ proposalHandler,
227
255
  blockSource,
228
256
  checkpointsBuilder,
229
257
  worldState,
230
258
  l1ToL2MessageSource,
231
259
  config,
232
260
  blobClient,
261
+ slashingProtectionSigner,
233
262
  dateProvider,
234
263
  telemetry,
235
264
  );
@@ -243,8 +272,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
243
272
  .filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
244
273
  }
245
274
 
246
- public getBlockProposalHandler() {
247
- return this.blockProposalHandler;
275
+ public getProposalHandler() {
276
+ return this.proposalHandler;
248
277
  }
249
278
 
250
279
  public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
@@ -267,6 +296,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
267
296
  this.config = { ...this.config, ...config };
268
297
  }
269
298
 
299
+ public reloadKeystore(newManager: KeystoreManager): void {
300
+ const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
301
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
302
+ this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
303
+ }
304
+
270
305
  public async start() {
271
306
  if (this.epochCacheUpdateLoop.isRunning()) {
272
307
  this.log.warn(`Validator client already started`);
@@ -311,7 +346,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
311
346
  checkpoint: CheckpointProposalCore,
312
347
  proposalSender: PeerId,
313
348
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
314
- this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
349
+ this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
350
+
351
+ // Duplicate proposal handler - triggers slashing for equivocation
352
+ this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
353
+ this.handleDuplicateProposal(info);
354
+ });
355
+
356
+ // Duplicate attestation handler - triggers slashing for attestation equivocation
357
+ this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
358
+ this.handleDuplicateAttestation(info);
359
+ });
315
360
 
316
361
  const myAddresses = this.getValidatorAddresses();
317
362
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
@@ -340,6 +385,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
340
385
  return false;
341
386
  }
342
387
 
388
+ // Log self-proposals from HA peers (same validator key on different nodes)
389
+ if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
390
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
391
+ proposer: proposer.toString(),
392
+ slotNumber,
393
+ });
394
+ }
395
+
343
396
  // Check if we're in the committee (for metrics purposes)
344
397
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
345
398
  const partOfCommittee = inCommittee.length > 0;
@@ -353,25 +406,25 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
353
406
 
354
407
  // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
355
408
  // In fisherman mode, we always reexecute to validate proposals.
356
- const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
357
- this.config;
409
+ const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
358
410
  const shouldReexecute =
359
411
  fishermanMode ||
360
- (slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
361
- (partOfCommittee && validatorReexecute) ||
412
+ slashBroadcastedInvalidBlockPenalty > 0n ||
413
+ partOfCommittee ||
362
414
  alwaysReexecuteBlockProposals ||
363
415
  this.blobClient.canUpload();
364
416
 
365
- const validationResult = await this.blockProposalHandler.handleBlockProposal(
417
+ const validationResult = await this.proposalHandler.handleBlockProposal(
366
418
  proposal,
367
419
  proposalSender,
368
420
  !!shouldReexecute && !escapeHatchOpen,
369
421
  );
370
422
 
371
423
  if (!validationResult.isValid) {
372
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
373
-
374
424
  const reason = validationResult.reason || 'unknown';
425
+
426
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
427
+
375
428
  // Classify failure reason: bad proposal vs node issue
376
429
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
377
430
  'invalid_proposal',
@@ -413,10 +466,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
413
466
  return false;
414
467
  }
415
468
 
416
- // TODO(palla/mbps): Remove this once checkpoint validation is stable.
417
- // Track that we successfully validated a block for this slot, so we can attest to checkpoint proposals for it.
418
- this.validatedBlockSlots.add(slotNumber);
419
-
420
469
  return true;
421
470
  }
422
471
 
@@ -430,61 +479,50 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
430
479
  proposal: CheckpointProposalCore,
431
480
  _proposalSender: PeerId,
432
481
  ): Promise<CheckpointAttestation[] | undefined> {
433
- const slotNumber = proposal.slotNumber;
482
+ const proposalSlotNumber = proposal.slotNumber;
434
483
  const proposer = proposal.getSender();
435
484
 
436
485
  // If escape hatch is open for this slot's epoch, do not attest.
437
- if (await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber)) {
438
- this.log.warn(`Escape hatch open for slot ${slotNumber}, skipping checkpoint attestation handling`);
486
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
487
+ this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
439
488
  return undefined;
440
489
  }
441
490
 
442
- // Reject proposals with invalid signatures
443
- if (!proposer) {
444
- this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
491
+ // Ignore proposals from ourselves (may happen in HA setups)
492
+ if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
493
+ this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
494
+ proposer: proposer.toString(),
495
+ proposalSlotNumber,
496
+ });
445
497
  return undefined;
446
498
  }
447
499
 
448
- // Check that I have any address in current committee before attesting
449
- const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
500
+ // Check that I have any address in the committee where this checkpoint will land before attesting
501
+ const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
450
502
  const partOfCommittee = inCommittee.length > 0;
451
503
 
452
504
  const proposalInfo = {
453
- slotNumber,
505
+ proposalSlotNumber,
454
506
  archive: proposal.archive.toString(),
455
- proposer: proposer.toString(),
456
- txCount: proposal.txHashes.length,
507
+ proposer: proposer?.toString(),
457
508
  };
458
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
509
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
459
510
  ...proposalInfo,
460
- txHashes: proposal.txHashes.map(t => t.toString()),
461
511
  fishermanMode: this.config.fishermanMode || false,
462
512
  });
463
513
 
464
- // TODO(palla/mbps): Remove this once checkpoint validation is stable.
465
- // Check that we have successfully validated a block for this slot before attesting to the checkpoint.
466
- if (!this.validatedBlockSlots.has(slotNumber)) {
467
- this.log.warn(`No validated block found for slot ${slotNumber}, refusing to attest to checkpoint`, proposalInfo);
468
- return undefined;
469
- }
470
-
471
- // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
472
- // TODO(palla/mbps): Change default to false once checkpoint validation is stable.
473
- if (this.config.skipCheckpointProposalValidation !== false) {
474
- this.log.verbose(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
514
+ // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
515
+ // Uses the cached result from the all-nodes callback if available (avoids double validation).
516
+ if (this.config.skipCheckpointProposalValidation) {
517
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
475
518
  } else {
476
- const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
519
+ const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
477
520
  if (!validationResult.isValid) {
478
521
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
479
522
  return undefined;
480
523
  }
481
524
  }
482
525
 
483
- // Upload blobs to filestore if we can (fire and forget)
484
- if (this.blobClient.canUpload()) {
485
- void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
486
- }
487
-
488
526
  // Check that I have any address in current committee before attesting
489
527
  // In fisherman mode, we still create attestations for validation even if not in committee
490
528
  if (!partOfCommittee && !this.config.fishermanMode) {
@@ -493,14 +531,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
493
531
  }
494
532
 
495
533
  // Provided all of the above checks pass, we can attest to the proposal
496
- this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, {
497
- ...proposalInfo,
498
- inCommittee: partOfCommittee,
499
- fishermanMode: this.config.fishermanMode || false,
500
- });
534
+ this.log.info(
535
+ `${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
536
+ {
537
+ ...proposalInfo,
538
+ inCommittee: partOfCommittee,
539
+ fishermanMode: this.config.fishermanMode || false,
540
+ },
541
+ );
501
542
 
502
543
  this.metrics.incSuccessfulAttestations(inCommittee.length);
503
544
 
545
+ // Track epoch participation per attester: count each (attester, epoch) pair at most once
546
+ const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
547
+ for (const attester of inCommittee) {
548
+ const key = attester.toString();
549
+ const lastEpoch = this.lastAttestedEpochByAttester.get(key);
550
+ if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
551
+ this.lastAttestedEpochByAttester.set(key, proposalEpoch);
552
+ this.metrics.incAttestedEpochCount(attester);
553
+ }
554
+ }
555
+
504
556
  // Determine which validators should attest
505
557
  let attestors: EthAddress[];
506
558
  if (partOfCommittee) {
@@ -519,167 +571,59 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
519
571
 
520
572
  if (this.config.fishermanMode) {
521
573
  // bail out early and don't save attestations to the pool in fisherman mode
522
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
574
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
523
575
  ...proposalInfo,
524
576
  attestors: attestors.map(a => a.toString()),
525
577
  });
526
578
  return undefined;
527
579
  }
528
580
 
529
- return this.createCheckpointAttestationsFromProposal(proposal, attestors);
530
- }
531
-
532
- private async createCheckpointAttestationsFromProposal(
533
- proposal: CheckpointProposalCore,
534
- attestors: EthAddress[] = [],
535
- ): Promise<CheckpointAttestation[]> {
536
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
537
- await this.p2pClient.addCheckpointAttestations(attestations);
538
- return attestations;
581
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
539
582
  }
540
583
 
541
584
  /**
542
- * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
543
- * @returns Validation result with isValid flag and reason if invalid.
585
+ * Checks if we should attest to a slot based on equivocation prevention rules.
586
+ * @returns true if we should attest, false if we should skip
544
587
  */
545
- private async validateCheckpointProposal(
546
- proposal: CheckpointProposalCore,
547
- proposalInfo: LogData,
548
- ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
549
- const slot = proposal.slotNumber;
550
- const timeoutSeconds = 10;
588
+ private shouldAttestToSlot(slotNumber: SlotNumber): boolean {
589
+ // If attestToEquivocatedProposals is true, always allow
590
+ if (this.config.attestToEquivocatedProposals) {
591
+ return true;
592
+ }
551
593
 
552
- // Wait for last block to sync by archive
553
- let lastBlockHeader: BlockHeader | undefined;
554
- try {
555
- lastBlockHeader = await retryUntil(
556
- async () => {
557
- await this.blockSource.syncImmediate();
558
- return this.blockSource.getBlockHeaderByArchive(proposal.archive);
559
- },
560
- `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
561
- timeoutSeconds,
562
- 0.5,
594
+ // Check if incoming slot is strictly greater than last attested
595
+ if (this.lastAttestedProposal && slotNumber <= this.lastAttestedProposal.slotNumber) {
596
+ this.log.warn(
597
+ `Refusing to process a proposal for slot ${slotNumber} given we already attested to a proposal for slot ${this.lastAttestedProposal.slotNumber}`,
563
598
  );
564
- } catch (err) {
565
- if (err instanceof TimeoutError) {
566
- this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
567
- return { isValid: false, reason: 'last_block_not_found' };
568
- }
569
- this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
570
- return { isValid: false, reason: 'block_fetch_error' };
599
+ return false;
571
600
  }
572
601
 
573
- if (!lastBlockHeader) {
574
- this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
575
- return { isValid: false, reason: 'last_block_not_found' };
576
- }
602
+ return true;
603
+ }
577
604
 
578
- // Get all full blocks for the slot and checkpoint
579
- const blocks = await this.blockSource.getBlocksForSlot(slot);
580
- if (blocks.length === 0) {
581
- this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
582
- return { isValid: false, reason: 'no_blocks_for_slot' };
605
+ private async createCheckpointAttestationsFromProposal(
606
+ proposal: CheckpointProposalCore,
607
+ attestors: EthAddress[] = [],
608
+ ): Promise<CheckpointAttestation[] | undefined> {
609
+ // Equivocation check: must happen right before signing to minimize the race window
610
+ if (!this.shouldAttestToSlot(proposal.slotNumber)) {
611
+ return undefined;
583
612
  }
584
613
 
585
- this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
586
- ...proposalInfo,
587
- blockNumbers: blocks.map(b => b.number),
588
- });
589
-
590
- // Get checkpoint constants from first block
591
- const firstBlock = blocks[0];
592
- const constants = this.extractCheckpointConstants(firstBlock);
593
- const checkpointNumber = firstBlock.checkpointNumber;
594
-
595
- // Get L1-to-L2 messages for this checkpoint
596
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
597
-
598
- // Compute the previous checkpoint out hashes for the epoch.
599
- // TODO: There can be a more efficient way to get the previous checkpoint out hashes without having to fetch the
600
- // actual checkpoints and the blocks/txs in them.
601
- const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
602
- const previousCheckpoints = (await this.blockSource.getCheckpointsForEpoch(epoch))
603
- .filter(b => b.number < checkpointNumber)
604
- .sort((a, b) => a.number - b.number);
605
- const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
606
-
607
- // Fork world state at the block before the first block
608
- const parentBlockNumber = BlockNumber(firstBlock.number - 1);
609
- const fork = await this.worldState.fork(parentBlockNumber);
610
-
611
- try {
612
- // Create checkpoint builder with all existing blocks
613
- const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
614
- checkpointNumber,
615
- constants,
616
- l1ToL2Messages,
617
- previousCheckpointOutHashes,
618
- fork,
619
- blocks,
620
- );
621
-
622
- // Complete the checkpoint to get computed values
623
- const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
624
-
625
- // Compare checkpoint header with proposal
626
- if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
627
- this.log.warn(`Checkpoint header mismatch`, {
628
- ...proposalInfo,
629
- computed: computedCheckpoint.header.toInspect(),
630
- proposal: proposal.checkpointHeader.toInspect(),
631
- });
632
- return { isValid: false, reason: 'checkpoint_header_mismatch' };
633
- }
634
-
635
- // Compare archive root with proposal
636
- if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
637
- this.log.warn(`Archive root mismatch`, {
638
- ...proposalInfo,
639
- computed: computedCheckpoint.archive.root.toString(),
640
- proposal: proposal.archive.toString(),
641
- });
642
- return { isValid: false, reason: 'archive_mismatch' };
643
- }
614
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
644
615
 
645
- // Check that the accumulated out hash matches the value in the proposal.
646
- const computedOutHash = computedCheckpoint.getCheckpointOutHash();
647
- const proposalOutHash = proposal.checkpointHeader.epochOutHash;
648
- if (!computedOutHash.equals(proposalOutHash)) {
649
- this.log.warn(`Epoch out hash mismatch`, {
650
- proposalOutHash: proposalOutHash.toString(),
651
- computedOutHash: computedOutHash.toString(),
652
- ...proposalInfo,
653
- });
654
- return { isValid: false, reason: 'out_hash_mismatch' };
655
- }
616
+ // Track the proposal we attested to (to prevent equivocation)
617
+ this.lastAttestedProposal = proposal;
656
618
 
657
- this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
658
- return { isValid: true };
659
- } finally {
660
- await fork.close();
661
- }
662
- }
663
-
664
- /**
665
- * Extract checkpoint global variables from a block.
666
- */
667
- private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
668
- const gv = block.header.globalVariables;
669
- return {
670
- chainId: gv.chainId,
671
- version: gv.version,
672
- slotNumber: gv.slotNumber,
673
- coinbase: gv.coinbase,
674
- feeRecipient: gv.feeRecipient,
675
- gasFees: gv.gasFees,
676
- };
619
+ await this.p2pClient.addOwnCheckpointAttestations(attestations);
620
+ return attestations;
677
621
  }
678
622
 
679
623
  /**
680
624
  * Uploads blobs for a checkpoint to the filestore (fire and forget).
681
625
  */
682
- private async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
626
+ protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
683
627
  try {
684
628
  const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
685
629
  if (!lastBlockHeader) {
@@ -694,7 +638,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
694
638
  }
695
639
 
696
640
  const blobFields = blocks.flatMap(b => b.toBlobFields());
697
- const blobs: Blob[] = getBlobsPerL1Block(blobFields);
641
+ const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
698
642
  await this.blobClient.sendBlobsToFilestore(blobs);
699
643
  this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
700
644
  ...proposalInfo,
@@ -732,6 +676,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
732
676
  ]);
733
677
  }
734
678
 
679
+ /**
680
+ * Handle detection of a duplicate proposal (equivocation).
681
+ * Emits a slash event when a proposer sends multiple proposals for the same position.
682
+ */
683
+ private handleDuplicateProposal(info: DuplicateProposalInfo): void {
684
+ const { slot, proposer, type } = info;
685
+
686
+ this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
687
+ proposer: proposer.toString(),
688
+ slot,
689
+ type,
690
+ });
691
+
692
+ // Emit slash event
693
+ this.emit(WANT_TO_SLASH_EVENT, [
694
+ {
695
+ validator: proposer,
696
+ amount: this.config.slashDuplicateProposalPenalty,
697
+ offenseType: OffenseType.DUPLICATE_PROPOSAL,
698
+ epochOrSlot: BigInt(slot),
699
+ },
700
+ ]);
701
+ }
702
+
703
+ /**
704
+ * Handle detection of a duplicate attestation (equivocation).
705
+ * Emits a slash event when an attester signs attestations for different proposals at the same slot.
706
+ */
707
+ private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
708
+ const { slot, attester } = info;
709
+
710
+ this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
711
+ attester: attester.toString(),
712
+ slot,
713
+ });
714
+
715
+ this.emit(WANT_TO_SLASH_EVENT, [
716
+ {
717
+ validator: attester,
718
+ amount: this.config.slashDuplicateAttestationPenalty,
719
+ offenseType: OffenseType.DUPLICATE_ATTESTATION,
720
+ epochOrSlot: BigInt(slot),
721
+ },
722
+ ]);
723
+ }
724
+
735
725
  async createBlockProposal(
736
726
  blockHeader: BlockHeader,
737
727
  indexWithinCheckpoint: IndexWithinCheckpoint,
@@ -739,13 +729,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
739
729
  archive: Fr,
740
730
  txs: Tx[],
741
731
  proposerAddress: EthAddress | undefined,
742
- options: BlockProposalOptions,
732
+ options: BlockProposalOptions = {},
743
733
  ): Promise<BlockProposal> {
744
- // TODO(palla/mbps): Prevent double proposals properly
745
- // if (this.previousProposal?.slotNumber === blockHeader.globalVariables.slotNumber) {
746
- // this.log.verbose(`Already made a proposal for the same slot, skipping proposal`);
747
- // return Promise.resolve(undefined);
748
- // }
734
+ // Validate that we're not creating a proposal for an older or equal position
735
+ if (this.lastProposedBlock) {
736
+ const lastSlot = this.lastProposedBlock.slotNumber;
737
+ const lastIndex = this.lastProposedBlock.indexWithinCheckpoint;
738
+ const newSlot = blockHeader.globalVariables.slotNumber;
739
+
740
+ if (newSlot < lastSlot || (newSlot === lastSlot && indexWithinCheckpoint <= lastIndex)) {
741
+ throw new Error(
742
+ `Cannot create block proposal for slot ${newSlot} index ${indexWithinCheckpoint}: ` +
743
+ `already proposed block for slot ${lastSlot} index ${lastIndex}`,
744
+ );
745
+ }
746
+ }
749
747
 
750
748
  this.log.info(
751
749
  `Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
@@ -762,25 +760,42 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
762
760
  broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
763
761
  },
764
762
  );
765
- this.previousProposal = newProposal;
763
+ this.lastProposedBlock = newProposal;
766
764
  return newProposal;
767
765
  }
768
766
 
769
767
  async createCheckpointProposal(
770
768
  checkpointHeader: CheckpointHeader,
771
769
  archive: Fr,
772
- lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
770
+ feeAssetPriceModifier: bigint,
771
+ lastBlockProposal: BlockProposal | undefined,
773
772
  proposerAddress: EthAddress | undefined,
774
- options: CheckpointProposalOptions,
773
+ options: CheckpointProposalOptions = {},
775
774
  ): Promise<CheckpointProposal> {
775
+ // Validate that we're not creating a proposal for an older or equal slot
776
+ if (this.lastProposedCheckpoint) {
777
+ const lastSlot = this.lastProposedCheckpoint.slotNumber;
778
+ const newSlot = checkpointHeader.slotNumber;
779
+
780
+ if (newSlot <= lastSlot) {
781
+ throw new Error(
782
+ `Cannot create checkpoint proposal for slot ${newSlot}: ` +
783
+ `already proposed checkpoint for slot ${lastSlot}`,
784
+ );
785
+ }
786
+ }
787
+
776
788
  this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
777
- return await this.validationService.createCheckpointProposal(
789
+ const newProposal = await this.validationService.createCheckpointProposal(
778
790
  checkpointHeader,
779
791
  archive,
780
- lastBlockInfo,
792
+ feeAssetPriceModifier,
793
+ lastBlockProposal,
781
794
  proposerAddress,
782
795
  options,
783
796
  );
797
+ this.lastProposedCheckpoint = newProposal;
798
+ return newProposal;
784
799
  }
785
800
 
786
801
  async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
@@ -802,6 +817,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
802
817
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
803
818
  const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
804
819
 
820
+ if (!attestations) {
821
+ return [];
822
+ }
823
+
805
824
  // We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
806
825
  // other nodes can see that our validators did attest to this block proposal, and do not slash us
807
826
  // due to inactivity for missed attestations.