@aztec/validator-client 0.0.1-commit.1bea0213 → 0.0.1-commit.21ecf947b

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/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,27 +19,28 @@ 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
26
  import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
26
- import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
27
+ import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
27
28
  import type {
28
29
  CreateCheckpointProposalLastBlockData,
30
+ ITxProvider,
29
31
  Validator,
30
32
  ValidatorClientFullConfig,
31
33
  WorldStateSynchronizer,
32
34
  } 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,
35
+ import { type L1ToL2MessageSource, accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging';
36
+ import {
37
+ type BlockProposal,
38
+ type BlockProposalOptions,
39
+ type CheckpointAttestation,
40
+ CheckpointProposal,
41
+ type CheckpointProposalCore,
42
+ type CheckpointProposalOptions,
40
43
  } from '@aztec/stdlib/p2p';
41
- import { CheckpointProposal } from '@aztec/stdlib/p2p';
42
44
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
43
45
  import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
44
46
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
@@ -79,18 +81,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
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;
87
92
 
88
93
  private proposersOfInvalidBlocks: Set<string> = new Set();
89
94
 
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();
95
+ /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
96
+ private lastAttestedProposal?: CheckpointProposalCore;
94
97
 
95
98
  protected constructor(
96
99
  private keyStore: ExtendedValidatorKeyStore,
@@ -184,7 +187,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
184
187
  p2pClient: P2P,
185
188
  blockSource: L2BlockSource & L2BlockSink,
186
189
  l1ToL2MessageSource: L1ToL2MessageSource,
187
- txProvider: TxProvider,
190
+ txProvider: ITxProvider,
188
191
  keyStoreManager: KeystoreManager,
189
192
  blobClient: BlobClientInterface,
190
193
  dateProvider: DateProvider = new DateProvider(),
@@ -313,6 +316,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
313
316
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
314
317
  this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
315
318
 
319
+ // Duplicate proposal handler - triggers slashing for equivocation
320
+ this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
321
+ this.handleDuplicateProposal(info);
322
+ });
323
+
324
+ // Duplicate attestation handler - triggers slashing for attestation equivocation
325
+ this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
326
+ this.handleDuplicateAttestation(info);
327
+ });
328
+
316
329
  const myAddresses = this.getValidatorAddresses();
317
330
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
318
331
 
@@ -340,6 +353,15 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
340
353
  return false;
341
354
  }
342
355
 
356
+ // Ignore proposals from ourselves (may happen in HA setups)
357
+ if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
358
+ this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
359
+ proposer: proposer.toString(),
360
+ slotNumber,
361
+ });
362
+ return false;
363
+ }
364
+
343
365
  // Check if we're in the committee (for metrics purposes)
344
366
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
345
367
  const partOfCommittee = inCommittee.length > 0;
@@ -413,10 +435,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
413
435
  return false;
414
436
  }
415
437
 
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
438
  return true;
421
439
  }
422
440
 
@@ -445,6 +463,23 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
445
463
  return undefined;
446
464
  }
447
465
 
466
+ // Ignore proposals from ourselves (may happen in HA setups)
467
+ if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
468
+ this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
469
+ proposer: proposer.toString(),
470
+ slotNumber,
471
+ });
472
+ return undefined;
473
+ }
474
+
475
+ // Validate fee asset price modifier is within allowed range
476
+ if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
477
+ this.log.warn(
478
+ `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`,
479
+ );
480
+ return undefined;
481
+ }
482
+
448
483
  // Check that I have any address in current committee before attesting
449
484
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
450
485
  const partOfCommittee = inCommittee.length > 0;
@@ -461,17 +496,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
461
496
  fishermanMode: this.config.fishermanMode || false,
462
497
  });
463
498
 
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
499
  // 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);
500
+ if (this.config.skipCheckpointProposalValidation) {
501
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
475
502
  } else {
476
503
  const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
477
504
  if (!validationResult.isValid) {
@@ -526,15 +553,45 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
526
553
  return undefined;
527
554
  }
528
555
 
529
- return this.createCheckpointAttestationsFromProposal(proposal, attestors);
556
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
557
+ }
558
+
559
+ /**
560
+ * Checks if we should attest to a slot based on equivocation prevention rules.
561
+ * @returns true if we should attest, false if we should skip
562
+ */
563
+ private shouldAttestToSlot(slotNumber: SlotNumber): boolean {
564
+ // If attestToEquivocatedProposals is true, always allow
565
+ if (this.config.attestToEquivocatedProposals) {
566
+ return true;
567
+ }
568
+
569
+ // Check if incoming slot is strictly greater than last attested
570
+ if (this.lastAttestedProposal && slotNumber <= this.lastAttestedProposal.slotNumber) {
571
+ this.log.warn(
572
+ `Refusing to process a proposal for slot ${slotNumber} given we already attested to a proposal for slot ${this.lastAttestedProposal.slotNumber}`,
573
+ );
574
+ return false;
575
+ }
576
+
577
+ return true;
530
578
  }
531
579
 
532
580
  private async createCheckpointAttestationsFromProposal(
533
581
  proposal: CheckpointProposalCore,
534
582
  attestors: EthAddress[] = [],
535
- ): Promise<CheckpointAttestation[]> {
583
+ ): Promise<CheckpointAttestation[] | undefined> {
584
+ // Equivocation check: must happen right before signing to minimize the race window
585
+ if (!this.shouldAttestToSlot(proposal.slotNumber)) {
586
+ return undefined;
587
+ }
588
+
536
589
  const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
537
- await this.p2pClient.addCheckpointAttestations(attestations);
590
+
591
+ // Track the proposal we attested to (to prevent equivocation)
592
+ this.lastAttestedProposal = proposal;
593
+
594
+ await this.p2pClient.addOwnCheckpointAttestations(attestations);
538
595
  return attestations;
539
596
  }
540
597
 
@@ -547,7 +604,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
547
604
  proposalInfo: LogData,
548
605
  ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
549
606
  const slot = proposal.slotNumber;
550
- const timeoutSeconds = 10;
607
+
608
+ // Timeout block syncing at the start of the next slot
609
+ const config = this.checkpointsBuilder.getConfig();
610
+ const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
611
+ const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
551
612
 
552
613
  // Wait for last block to sync by archive
553
614
  let lastBlockHeader: BlockHeader | undefined;
@@ -613,10 +674,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
613
674
  const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
614
675
  checkpointNumber,
615
676
  constants,
677
+ proposal.feeAssetPriceModifier,
616
678
  l1ToL2Messages,
617
679
  previousCheckpointOutHashes,
618
680
  fork,
619
681
  blocks,
682
+ this.log.getBindings(),
620
683
  );
621
684
 
622
685
  // Complete the checkpoint to get computed values
@@ -642,13 +705,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
642
705
  return { isValid: false, reason: 'archive_mismatch' };
643
706
  }
644
707
 
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)) {
708
+ // Check that the accumulated epoch out hash matches the value in the proposal.
709
+ // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
710
+ const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
711
+ const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
712
+ const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
713
+ if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
649
714
  this.log.warn(`Epoch out hash mismatch`, {
650
- proposalOutHash: proposalOutHash.toString(),
651
- computedOutHash: computedOutHash.toString(),
715
+ proposalEpochOutHash: proposalEpochOutHash.toString(),
716
+ computedEpochOutHash: computedEpochOutHash.toString(),
717
+ checkpointOutHash: checkpointOutHash.toString(),
718
+ previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
652
719
  ...proposalInfo,
653
720
  });
654
721
  return { isValid: false, reason: 'out_hash_mismatch' };
@@ -732,6 +799,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
732
799
  ]);
733
800
  }
734
801
 
802
+ /**
803
+ * Handle detection of a duplicate proposal (equivocation).
804
+ * Emits a slash event when a proposer sends multiple proposals for the same position.
805
+ */
806
+ private handleDuplicateProposal(info: DuplicateProposalInfo): void {
807
+ const { slot, proposer, type } = info;
808
+
809
+ this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
810
+ proposer: proposer.toString(),
811
+ slot,
812
+ type,
813
+ });
814
+
815
+ // Emit slash event
816
+ this.emit(WANT_TO_SLASH_EVENT, [
817
+ {
818
+ validator: proposer,
819
+ amount: this.config.slashDuplicateProposalPenalty,
820
+ offenseType: OffenseType.DUPLICATE_PROPOSAL,
821
+ epochOrSlot: BigInt(slot),
822
+ },
823
+ ]);
824
+ }
825
+
826
+ /**
827
+ * Handle detection of a duplicate attestation (equivocation).
828
+ * Emits a slash event when an attester signs attestations for different proposals at the same slot.
829
+ */
830
+ private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
831
+ const { slot, attester } = info;
832
+
833
+ this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
834
+ attester: attester.toString(),
835
+ slot,
836
+ });
837
+
838
+ this.emit(WANT_TO_SLASH_EVENT, [
839
+ {
840
+ validator: attester,
841
+ amount: this.config.slashDuplicateAttestationPenalty,
842
+ offenseType: OffenseType.DUPLICATE_ATTESTATION,
843
+ epochOrSlot: BigInt(slot),
844
+ },
845
+ ]);
846
+ }
847
+
735
848
  async createBlockProposal(
736
849
  blockHeader: BlockHeader,
737
850
  indexWithinCheckpoint: IndexWithinCheckpoint,
@@ -739,13 +852,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
739
852
  archive: Fr,
740
853
  txs: Tx[],
741
854
  proposerAddress: EthAddress | undefined,
742
- options: BlockProposalOptions,
855
+ options: BlockProposalOptions = {},
743
856
  ): 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
- // }
857
+ // Validate that we're not creating a proposal for an older or equal position
858
+ if (this.lastProposedBlock) {
859
+ const lastSlot = this.lastProposedBlock.slotNumber;
860
+ const lastIndex = this.lastProposedBlock.indexWithinCheckpoint;
861
+ const newSlot = blockHeader.globalVariables.slotNumber;
862
+
863
+ if (newSlot < lastSlot || (newSlot === lastSlot && indexWithinCheckpoint <= lastIndex)) {
864
+ throw new Error(
865
+ `Cannot create block proposal for slot ${newSlot} index ${indexWithinCheckpoint}: ` +
866
+ `already proposed block for slot ${lastSlot} index ${lastIndex}`,
867
+ );
868
+ }
869
+ }
749
870
 
750
871
  this.log.info(
751
872
  `Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
@@ -762,25 +883,42 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
762
883
  broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
763
884
  },
764
885
  );
765
- this.previousProposal = newProposal;
886
+ this.lastProposedBlock = newProposal;
766
887
  return newProposal;
767
888
  }
768
889
 
769
890
  async createCheckpointProposal(
770
891
  checkpointHeader: CheckpointHeader,
771
892
  archive: Fr,
893
+ feeAssetPriceModifier: bigint,
772
894
  lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
773
895
  proposerAddress: EthAddress | undefined,
774
- options: CheckpointProposalOptions,
896
+ options: CheckpointProposalOptions = {},
775
897
  ): Promise<CheckpointProposal> {
898
+ // Validate that we're not creating a proposal for an older or equal slot
899
+ if (this.lastProposedCheckpoint) {
900
+ const lastSlot = this.lastProposedCheckpoint.slotNumber;
901
+ const newSlot = checkpointHeader.slotNumber;
902
+
903
+ if (newSlot <= lastSlot) {
904
+ throw new Error(
905
+ `Cannot create checkpoint proposal for slot ${newSlot}: ` +
906
+ `already proposed checkpoint for slot ${lastSlot}`,
907
+ );
908
+ }
909
+ }
910
+
776
911
  this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
777
- return await this.validationService.createCheckpointProposal(
912
+ const newProposal = await this.validationService.createCheckpointProposal(
778
913
  checkpointHeader,
779
914
  archive,
915
+ feeAssetPriceModifier,
780
916
  lastBlockInfo,
781
917
  proposerAddress,
782
918
  options,
783
919
  );
920
+ this.lastProposedCheckpoint = newProposal;
921
+ return newProposal;
784
922
  }
785
923
 
786
924
  async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
@@ -802,6 +940,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
802
940
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
803
941
  const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
804
942
 
943
+ if (!attestations) {
944
+ return [];
945
+ }
946
+
805
947
  // We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
806
948
  // other nodes can see that our validators did attest to this block proposal, and do not slash us
807
949
  // due to inactivity for missed attestations.