@aztec/validator-client 0.0.1-commit.3469e52 → 0.0.1-commit.35158ae7e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +63 -19
  2. package/dest/block_proposal_handler.d.ts +9 -10
  3. package/dest/block_proposal_handler.d.ts.map +1 -1
  4. package/dest/block_proposal_handler.js +135 -82
  5. package/dest/checkpoint_builder.d.ts +27 -15
  6. package/dest/checkpoint_builder.d.ts.map +1 -1
  7. package/dest/checkpoint_builder.js +136 -45
  8. package/dest/config.d.ts +1 -1
  9. package/dest/config.d.ts.map +1 -1
  10. package/dest/config.js +30 -7
  11. package/dest/duties/validation_service.d.ts +2 -2
  12. package/dest/duties/validation_service.d.ts.map +1 -1
  13. package/dest/duties/validation_service.js +6 -12
  14. package/dest/factory.d.ts +3 -1
  15. package/dest/factory.d.ts.map +1 -1
  16. package/dest/factory.js +3 -2
  17. package/dest/index.d.ts +1 -2
  18. package/dest/index.d.ts.map +1 -1
  19. package/dest/index.js +0 -1
  20. package/dest/key_store/ha_key_store.d.ts +1 -1
  21. package/dest/key_store/ha_key_store.d.ts.map +1 -1
  22. package/dest/key_store/ha_key_store.js +3 -3
  23. package/dest/metrics.d.ts +12 -3
  24. package/dest/metrics.d.ts.map +1 -1
  25. package/dest/metrics.js +46 -5
  26. package/dest/validator.d.ts +41 -15
  27. package/dest/validator.d.ts.map +1 -1
  28. package/dest/validator.js +230 -69
  29. package/package.json +19 -17
  30. package/src/block_proposal_handler.ts +165 -109
  31. package/src/checkpoint_builder.ts +185 -52
  32. package/src/config.ts +30 -7
  33. package/src/duties/validation_service.ts +12 -11
  34. package/src/factory.ts +4 -0
  35. package/src/index.ts +0 -1
  36. package/src/key_store/ha_key_store.ts +3 -3
  37. package/src/metrics.ts +63 -6
  38. package/src/validator.ts +294 -86
  39. package/dest/tx_validator/index.d.ts +0 -3
  40. package/dest/tx_validator/index.d.ts.map +0 -1
  41. package/dest/tx_validator/index.js +0 -2
  42. package/dest/tx_validator/nullifier_cache.d.ts +0 -14
  43. package/dest/tx_validator/nullifier_cache.d.ts.map +0 -1
  44. package/dest/tx_validator/nullifier_cache.js +0 -24
  45. package/dest/tx_validator/tx_validator_factory.d.ts +0 -18
  46. package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
  47. package/dest/tx_validator/tx_validator_factory.js +0 -54
  48. package/src/tx_validator/index.ts +0 -2
  49. package/src/tx_validator/nullifier_cache.ts +0 -30
  50. package/src/tx_validator/tx_validator_factory.ts +0 -135
package/src/validator.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { BlobClientInterface } from '@aztec/blob-client/client';
2
2
  import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib';
3
3
  import type { EpochCache } from '@aztec/epoch-cache';
4
+ import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
4
5
  import {
5
6
  BlockNumber,
6
7
  CheckpointNumber,
@@ -18,33 +19,40 @@ import { RunningPromise } from '@aztec/foundation/running-promise';
18
19
  import { sleep } from '@aztec/foundation/sleep';
19
20
  import { DateProvider } from '@aztec/foundation/timer';
20
21
  import type { KeystoreManager } from '@aztec/node-keystore';
21
- import type { P2P, PeerId, TxProvider } from '@aztec/p2p';
22
+ import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
22
23
  import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
23
24
  import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
24
25
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
25
- import type { CommitteeAttestationsAndSigners, L2BlockNew, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
26
- import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
26
+ import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
27
+ import { validateCheckpoint } from '@aztec/stdlib/checkpoint';
28
+ import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
27
29
  import type {
28
30
  CreateCheckpointProposalLastBlockData,
31
+ ITxProvider,
29
32
  Validator,
30
33
  ValidatorClientFullConfig,
31
34
  WorldStateSynchronizer,
32
35
  } from '@aztec/stdlib/interfaces/server';
33
- import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
34
- import type {
35
- BlockProposal,
36
- BlockProposalOptions,
37
- CheckpointAttestation,
38
- CheckpointProposalCore,
39
- CheckpointProposalOptions,
36
+ import { type L1ToL2MessageSource, accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging';
37
+ import {
38
+ type BlockProposal,
39
+ type BlockProposalOptions,
40
+ type CheckpointAttestation,
41
+ CheckpointProposal,
42
+ type CheckpointProposalCore,
43
+ type CheckpointProposalOptions,
40
44
  } from '@aztec/stdlib/p2p';
41
- import { CheckpointProposal } from '@aztec/stdlib/p2p';
42
45
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
43
46
  import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
44
47
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
45
48
  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';
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';
55
+ import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
48
56
 
49
57
  import { EventEmitter } from 'events';
50
58
  import type { TypedDataDefinition } from 'viem';
@@ -75,22 +83,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
75
83
  private validationService: ValidationService;
76
84
  private metrics: ValidatorMetrics;
77
85
  private log: Logger;
78
-
79
86
  // Whether it has already registered handlers on the p2p client
80
87
  private hasRegisteredHandlers = false;
81
88
 
82
- // Used to check if we are sending the same proposal twice
83
- private previousProposal?: BlockProposal;
89
+ /** Tracks the last block proposal we created, to detect duplicate proposal attempts. */
90
+ private lastProposedBlock?: BlockProposal;
91
+
92
+ /** Tracks the last checkpoint proposal we created. */
93
+ private lastProposedCheckpoint?: CheckpointProposal;
84
94
 
85
95
  private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
86
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();
87
99
 
88
100
  private proposersOfInvalidBlocks: Set<string> = new Set();
89
101
 
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();
102
+ /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
103
+ private lastAttestedProposal?: CheckpointProposalCore;
94
104
 
95
105
  protected constructor(
96
106
  private keyStore: ExtendedValidatorKeyStore,
@@ -103,6 +113,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
103
113
  private l1ToL2MessageSource: L1ToL2MessageSource,
104
114
  private config: ValidatorClientFullConfig,
105
115
  private blobClient: BlobClientInterface,
116
+ private slashingProtectionSigner: ValidatorHASigner,
106
117
  private dateProvider: DateProvider = new DateProvider(),
107
118
  telemetry: TelemetryClient = getTelemetryClient(),
108
119
  log = createLogger('validator'),
@@ -156,6 +167,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
156
167
  this.log.trace(`No committee found for slot`);
157
168
  return;
158
169
  }
170
+ this.metrics.setCurrentEpoch(epoch);
159
171
  if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
160
172
  const me = this.getValidatorAddresses();
161
173
  const committeeSet = new Set(committee.map(v => v.toString()));
@@ -184,15 +196,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
184
196
  p2pClient: P2P,
185
197
  blockSource: L2BlockSource & L2BlockSink,
186
198
  l1ToL2MessageSource: L1ToL2MessageSource,
187
- txProvider: TxProvider,
199
+ txProvider: ITxProvider,
188
200
  keyStoreManager: KeystoreManager,
189
201
  blobClient: BlobClientInterface,
190
202
  dateProvider: DateProvider = new DateProvider(),
191
203
  telemetry: TelemetryClient = getTelemetryClient(),
204
+ slashingProtectionDb?: SlashingProtectionDatabase,
192
205
  ) {
193
206
  const metrics = new ValidatorMetrics(telemetry);
194
207
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
195
208
  txsPermitted: !config.disableTransactions,
209
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
196
210
  });
197
211
  const blockProposalHandler = new BlockProposalHandler(
198
212
  checkpointsBuilder,
@@ -208,16 +222,34 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
208
222
  telemetry,
209
223
  );
210
224
 
211
- let validatorKeyStore: ExtendedValidatorKeyStore = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
212
- if (config.haSigningEnabled) {
225
+ const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
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.
213
235
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
214
236
  const haConfig = {
215
237
  ...config,
216
238
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
217
239
  };
218
- const { signer } = await createHASigner(haConfig);
219
- validatorKeyStore = new HAKeyStore(validatorKeyStore, 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
+ }));
220
251
  }
252
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
221
253
 
222
254
  const validator = new ValidatorClient(
223
255
  validatorKeyStore,
@@ -230,6 +262,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
230
262
  l1ToL2MessageSource,
231
263
  config,
232
264
  blobClient,
265
+ slashingProtectionSigner,
233
266
  dateProvider,
234
267
  telemetry,
235
268
  );
@@ -267,6 +300,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
267
300
  this.config = { ...this.config, ...config };
268
301
  }
269
302
 
303
+ public reloadKeystore(newManager: KeystoreManager): void {
304
+ const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
305
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
306
+ this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
307
+ }
308
+
270
309
  public async start() {
271
310
  if (this.epochCacheUpdateLoop.isRunning()) {
272
311
  this.log.warn(`Validator client already started`);
@@ -313,6 +352,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
313
352
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
314
353
  this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
315
354
 
355
+ // Duplicate proposal handler - triggers slashing for equivocation
356
+ this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
357
+ this.handleDuplicateProposal(info);
358
+ });
359
+
360
+ // Duplicate attestation handler - triggers slashing for attestation equivocation
361
+ this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
362
+ this.handleDuplicateAttestation(info);
363
+ });
364
+
316
365
  const myAddresses = this.getValidatorAddresses();
317
366
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
318
367
 
@@ -340,6 +389,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
340
389
  return false;
341
390
  }
342
391
 
392
+ // Log self-proposals from HA peers (same validator key on different nodes)
393
+ if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
394
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
395
+ proposer: proposer.toString(),
396
+ slotNumber,
397
+ });
398
+ }
399
+
343
400
  // Check if we're in the committee (for metrics purposes)
344
401
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
345
402
  const partOfCommittee = inCommittee.length > 0;
@@ -369,9 +426,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
369
426
  );
370
427
 
371
428
  if (!validationResult.isValid) {
372
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
373
-
374
429
  const reason = validationResult.reason || 'unknown';
430
+
431
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
432
+
375
433
  // Classify failure reason: bad proposal vs node issue
376
434
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
377
435
  'invalid_proposal',
@@ -413,10 +471,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
413
471
  return false;
414
472
  }
415
473
 
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
474
  return true;
421
475
  }
422
476
 
@@ -430,48 +484,55 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
430
484
  proposal: CheckpointProposalCore,
431
485
  _proposalSender: PeerId,
432
486
  ): Promise<CheckpointAttestation[] | undefined> {
433
- const slotNumber = proposal.slotNumber;
487
+ const proposalSlotNumber = proposal.slotNumber;
434
488
  const proposer = proposal.getSender();
435
489
 
436
490
  // 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`);
491
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
492
+ this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
439
493
  return undefined;
440
494
  }
441
495
 
442
496
  // Reject proposals with invalid signatures
443
497
  if (!proposer) {
444
- 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}`);
445
499
  return undefined;
446
500
  }
447
501
 
448
- // Check that I have any address in current committee before attesting
449
- const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
502
+ // Ignore proposals from ourselves (may happen in HA setups)
503
+ if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
504
+ this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
505
+ proposer: proposer.toString(),
506
+ proposalSlotNumber,
507
+ });
508
+ return undefined;
509
+ }
510
+
511
+ // Validate fee asset price modifier is within allowed range
512
+ if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
513
+ this.log.warn(
514
+ `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${proposalSlotNumber}`,
515
+ );
516
+ return undefined;
517
+ }
518
+
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());
450
521
  const partOfCommittee = inCommittee.length > 0;
451
522
 
452
523
  const proposalInfo = {
453
- slotNumber,
524
+ proposalSlotNumber,
454
525
  archive: proposal.archive.toString(),
455
526
  proposer: proposer.toString(),
456
- txCount: proposal.txHashes.length,
457
527
  };
458
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
528
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
459
529
  ...proposalInfo,
460
- txHashes: proposal.txHashes.map(t => t.toString()),
461
530
  fishermanMode: this.config.fishermanMode || false,
462
531
  });
463
532
 
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
533
  // 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);
534
+ if (this.config.skipCheckpointProposalValidation) {
535
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
475
536
  } else {
476
537
  const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
477
538
  if (!validationResult.isValid) {
@@ -493,14 +554,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
493
554
  }
494
555
 
495
556
  // 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
- });
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
+ );
501
565
 
502
566
  this.metrics.incSuccessfulAttestations(inCommittee.length);
503
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
+
504
579
  // Determine which validators should attest
505
580
  let attestors: EthAddress[];
506
581
  if (partOfCommittee) {
@@ -519,22 +594,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
519
594
 
520
595
  if (this.config.fishermanMode) {
521
596
  // bail out early and don't save attestations to the pool in fisherman mode
522
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
597
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
523
598
  ...proposalInfo,
524
599
  attestors: attestors.map(a => a.toString()),
525
600
  });
526
601
  return undefined;
527
602
  }
528
603
 
529
- return this.createCheckpointAttestationsFromProposal(proposal, attestors);
604
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
605
+ }
606
+
607
+ /**
608
+ * Checks if we should attest to a slot based on equivocation prevention rules.
609
+ * @returns true if we should attest, false if we should skip
610
+ */
611
+ private shouldAttestToSlot(slotNumber: SlotNumber): boolean {
612
+ // If attestToEquivocatedProposals is true, always allow
613
+ if (this.config.attestToEquivocatedProposals) {
614
+ return true;
615
+ }
616
+
617
+ // Check if incoming slot is strictly greater than last attested
618
+ if (this.lastAttestedProposal && slotNumber <= this.lastAttestedProposal.slotNumber) {
619
+ this.log.warn(
620
+ `Refusing to process a proposal for slot ${slotNumber} given we already attested to a proposal for slot ${this.lastAttestedProposal.slotNumber}`,
621
+ );
622
+ return false;
623
+ }
624
+
625
+ return true;
530
626
  }
531
627
 
532
628
  private async createCheckpointAttestationsFromProposal(
533
629
  proposal: CheckpointProposalCore,
534
630
  attestors: EthAddress[] = [],
535
- ): Promise<CheckpointAttestation[]> {
631
+ ): Promise<CheckpointAttestation[] | undefined> {
632
+ // Equivocation check: must happen right before signing to minimize the race window
633
+ if (!this.shouldAttestToSlot(proposal.slotNumber)) {
634
+ return undefined;
635
+ }
636
+
536
637
  const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
537
- await this.p2pClient.addCheckpointAttestations(attestations);
638
+
639
+ // Track the proposal we attested to (to prevent equivocation)
640
+ this.lastAttestedProposal = proposal;
641
+
642
+ await this.p2pClient.addOwnCheckpointAttestations(attestations);
538
643
  return attestations;
539
644
  }
540
645
 
@@ -547,7 +652,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
547
652
  proposalInfo: LogData,
548
653
  ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
549
654
  const slot = proposal.slotNumber;
550
- const timeoutSeconds = 10;
655
+
656
+ // Timeout block syncing at the start of the next slot
657
+ const config = this.checkpointsBuilder.getConfig();
658
+ const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
659
+ const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
551
660
 
552
661
  // Wait for last block to sync by archive
553
662
  let lastBlockHeader: BlockHeader | undefined;
@@ -582,6 +691,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
582
691
  return { isValid: false, reason: 'no_blocks_for_slot' };
583
692
  }
584
693
 
694
+ // Ensure the last block for this slot matches the archive in the checkpoint proposal
695
+ if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
696
+ this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
697
+ return { isValid: false, reason: 'last_block_archive_mismatch' };
698
+ }
699
+
585
700
  this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
586
701
  ...proposalInfo,
587
702
  blockNumbers: blocks.map(b => b.number),
@@ -595,14 +710,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
595
710
  // Get L1-to-L2 messages for this checkpoint
596
711
  const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
597
712
 
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.
713
+ // Collect the out hashes of all the checkpoints before this one in the same epoch
601
714
  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());
715
+ const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
716
+ .filter(c => c.checkpointNumber < checkpointNumber)
717
+ .map(c => c.checkpointOutHash);
606
718
 
607
719
  // Fork world state at the block before the first block
608
720
  const parentBlockNumber = BlockNumber(firstBlock.number - 1);
@@ -613,10 +725,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
613
725
  const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
614
726
  checkpointNumber,
615
727
  constants,
728
+ proposal.feeAssetPriceModifier,
616
729
  l1ToL2Messages,
617
730
  previousCheckpointOutHashes,
618
731
  fork,
619
732
  blocks,
733
+ this.log.getBindings(),
620
734
  );
621
735
 
622
736
  // Complete the checkpoint to get computed values
@@ -642,18 +756,36 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
642
756
  return { isValid: false, reason: 'archive_mismatch' };
643
757
  }
644
758
 
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)) {
759
+ // Check that the accumulated epoch out hash matches the value in the proposal.
760
+ // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
761
+ const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
762
+ const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
763
+ const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
764
+ if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
649
765
  this.log.warn(`Epoch out hash mismatch`, {
650
- proposalOutHash: proposalOutHash.toString(),
651
- computedOutHash: computedOutHash.toString(),
766
+ proposalEpochOutHash: proposalEpochOutHash.toString(),
767
+ computedEpochOutHash: computedEpochOutHash.toString(),
768
+ checkpointOutHash: checkpointOutHash.toString(),
769
+ previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
652
770
  ...proposalInfo,
653
771
  });
654
772
  return { isValid: false, reason: 'out_hash_mismatch' };
655
773
  }
656
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
+
657
789
  this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
658
790
  return { isValid: true };
659
791
  } finally {
@@ -664,12 +796,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
664
796
  /**
665
797
  * Extract checkpoint global variables from a block.
666
798
  */
667
- private extractCheckpointConstants(block: L2BlockNew): CheckpointGlobalVariables {
799
+ private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
668
800
  const gv = block.header.globalVariables;
669
801
  return {
670
802
  chainId: gv.chainId,
671
803
  version: gv.version,
672
804
  slotNumber: gv.slotNumber,
805
+ timestamp: gv.timestamp,
673
806
  coinbase: gv.coinbase,
674
807
  feeRecipient: gv.feeRecipient,
675
808
  gasFees: gv.gasFees,
@@ -679,7 +812,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
679
812
  /**
680
813
  * Uploads blobs for a checkpoint to the filestore (fire and forget).
681
814
  */
682
- private async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
815
+ protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
683
816
  try {
684
817
  const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
685
818
  if (!lastBlockHeader) {
@@ -694,7 +827,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
694
827
  }
695
828
 
696
829
  const blobFields = blocks.flatMap(b => b.toBlobFields());
697
- const blobs: Blob[] = getBlobsPerL1Block(blobFields);
830
+ const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
698
831
  await this.blobClient.sendBlobsToFilestore(blobs);
699
832
  this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
700
833
  ...proposalInfo,
@@ -732,6 +865,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
732
865
  ]);
733
866
  }
734
867
 
868
+ /**
869
+ * Handle detection of a duplicate proposal (equivocation).
870
+ * Emits a slash event when a proposer sends multiple proposals for the same position.
871
+ */
872
+ private handleDuplicateProposal(info: DuplicateProposalInfo): void {
873
+ const { slot, proposer, type } = info;
874
+
875
+ this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
876
+ proposer: proposer.toString(),
877
+ slot,
878
+ type,
879
+ });
880
+
881
+ // Emit slash event
882
+ this.emit(WANT_TO_SLASH_EVENT, [
883
+ {
884
+ validator: proposer,
885
+ amount: this.config.slashDuplicateProposalPenalty,
886
+ offenseType: OffenseType.DUPLICATE_PROPOSAL,
887
+ epochOrSlot: BigInt(slot),
888
+ },
889
+ ]);
890
+ }
891
+
892
+ /**
893
+ * Handle detection of a duplicate attestation (equivocation).
894
+ * Emits a slash event when an attester signs attestations for different proposals at the same slot.
895
+ */
896
+ private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
897
+ const { slot, attester } = info;
898
+
899
+ this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
900
+ attester: attester.toString(),
901
+ slot,
902
+ });
903
+
904
+ this.emit(WANT_TO_SLASH_EVENT, [
905
+ {
906
+ validator: attester,
907
+ amount: this.config.slashDuplicateAttestationPenalty,
908
+ offenseType: OffenseType.DUPLICATE_ATTESTATION,
909
+ epochOrSlot: BigInt(slot),
910
+ },
911
+ ]);
912
+ }
913
+
735
914
  async createBlockProposal(
736
915
  blockHeader: BlockHeader,
737
916
  indexWithinCheckpoint: IndexWithinCheckpoint,
@@ -739,13 +918,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
739
918
  archive: Fr,
740
919
  txs: Tx[],
741
920
  proposerAddress: EthAddress | undefined,
742
- options: BlockProposalOptions,
921
+ options: BlockProposalOptions = {},
743
922
  ): 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
- // }
923
+ // Validate that we're not creating a proposal for an older or equal position
924
+ if (this.lastProposedBlock) {
925
+ const lastSlot = this.lastProposedBlock.slotNumber;
926
+ const lastIndex = this.lastProposedBlock.indexWithinCheckpoint;
927
+ const newSlot = blockHeader.globalVariables.slotNumber;
928
+
929
+ if (newSlot < lastSlot || (newSlot === lastSlot && indexWithinCheckpoint <= lastIndex)) {
930
+ throw new Error(
931
+ `Cannot create block proposal for slot ${newSlot} index ${indexWithinCheckpoint}: ` +
932
+ `already proposed block for slot ${lastSlot} index ${lastIndex}`,
933
+ );
934
+ }
935
+ }
749
936
 
750
937
  this.log.info(
751
938
  `Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
@@ -762,25 +949,42 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
762
949
  broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
763
950
  },
764
951
  );
765
- this.previousProposal = newProposal;
952
+ this.lastProposedBlock = newProposal;
766
953
  return newProposal;
767
954
  }
768
955
 
769
956
  async createCheckpointProposal(
770
957
  checkpointHeader: CheckpointHeader,
771
958
  archive: Fr,
959
+ feeAssetPriceModifier: bigint,
772
960
  lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
773
961
  proposerAddress: EthAddress | undefined,
774
- options: CheckpointProposalOptions,
962
+ options: CheckpointProposalOptions = {},
775
963
  ): Promise<CheckpointProposal> {
964
+ // Validate that we're not creating a proposal for an older or equal slot
965
+ if (this.lastProposedCheckpoint) {
966
+ const lastSlot = this.lastProposedCheckpoint.slotNumber;
967
+ const newSlot = checkpointHeader.slotNumber;
968
+
969
+ if (newSlot <= lastSlot) {
970
+ throw new Error(
971
+ `Cannot create checkpoint proposal for slot ${newSlot}: ` +
972
+ `already proposed checkpoint for slot ${lastSlot}`,
973
+ );
974
+ }
975
+ }
976
+
776
977
  this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
777
- return await this.validationService.createCheckpointProposal(
978
+ const newProposal = await this.validationService.createCheckpointProposal(
778
979
  checkpointHeader,
779
980
  archive,
981
+ feeAssetPriceModifier,
780
982
  lastBlockInfo,
781
983
  proposerAddress,
782
984
  options,
783
985
  );
986
+ this.lastProposedCheckpoint = newProposal;
987
+ return newProposal;
784
988
  }
785
989
 
786
990
  async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
@@ -802,6 +1006,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
802
1006
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
803
1007
  const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
804
1008
 
1009
+ if (!attestations) {
1010
+ return [];
1011
+ }
1012
+
805
1013
  // We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
806
1014
  // other nodes can see that our validators did attest to this block proposal, and do not slash us
807
1015
  // due to inactivity for missed attestations.