@aztec/validator-client 0.0.1-commit.18ccd8f0 → 0.0.1-commit.1bb068fb5

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,12 +19,12 @@ 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 } 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,
29
30
  ITxProvider,
@@ -32,14 +33,14 @@ import type {
32
33
  WorldStateSynchronizer,
33
34
  } from '@aztec/stdlib/interfaces/server';
34
35
  import { type L1ToL2MessageSource, accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging';
35
- import type {
36
- BlockProposal,
37
- BlockProposalOptions,
38
- CheckpointAttestation,
39
- CheckpointProposalCore,
40
- CheckpointProposalOptions,
36
+ import {
37
+ type BlockProposal,
38
+ type BlockProposalOptions,
39
+ type CheckpointAttestation,
40
+ CheckpointProposal,
41
+ type CheckpointProposalCore,
42
+ type CheckpointProposalOptions,
41
43
  } from '@aztec/stdlib/p2p';
42
- import { CheckpointProposal } from '@aztec/stdlib/p2p';
43
44
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
44
45
  import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
45
46
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
@@ -80,14 +81,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
80
81
  // Whether it has already registered handlers on the p2p client
81
82
  private hasRegisteredHandlers = false;
82
83
 
83
- // Used to check if we are sending the same proposal twice
84
- 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;
85
89
 
86
90
  private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
87
91
  private epochCacheUpdateLoop: RunningPromise;
88
92
 
89
93
  private proposersOfInvalidBlocks: Set<string> = new Set();
90
94
 
95
+ /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
96
+ private lastAttestedProposal?: CheckpointProposalCore;
97
+
91
98
  protected constructor(
92
99
  private keyStore: ExtendedValidatorKeyStore,
93
100
  private epochCache: EpochCache,
@@ -309,6 +316,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
309
316
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
310
317
  this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
311
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
+
312
329
  const myAddresses = this.getValidatorAddresses();
313
330
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
314
331
 
@@ -336,6 +353,15 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
336
353
  return false;
337
354
  }
338
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
+
339
365
  // Check if we're in the committee (for metrics purposes)
340
366
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
341
367
  const partOfCommittee = inCommittee.length > 0;
@@ -437,6 +463,23 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
437
463
  return undefined;
438
464
  }
439
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
+
440
483
  // Check that I have any address in current committee before attesting
441
484
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
442
485
  const partOfCommittee = inCommittee.length > 0;
@@ -510,15 +553,45 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
510
553
  return undefined;
511
554
  }
512
555
 
513
- 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;
514
578
  }
515
579
 
516
580
  private async createCheckpointAttestationsFromProposal(
517
581
  proposal: CheckpointProposalCore,
518
582
  attestors: EthAddress[] = [],
519
- ): 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
+
520
589
  const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
521
- 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);
522
595
  return attestations;
523
596
  }
524
597
 
@@ -531,7 +604,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
531
604
  proposalInfo: LogData,
532
605
  ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
533
606
  const slot = proposal.slotNumber;
534
- const timeoutSeconds = 10; // TODO(palla/mbps): This should map to the timetable settings
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));
535
612
 
536
613
  // Wait for last block to sync by archive
537
614
  let lastBlockHeader: BlockHeader | undefined;
@@ -597,6 +674,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
597
674
  const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
598
675
  checkpointNumber,
599
676
  constants,
677
+ proposal.feeAssetPriceModifier,
600
678
  l1ToL2Messages,
601
679
  previousCheckpointOutHashes,
602
680
  fork,
@@ -721,6 +799,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
721
799
  ]);
722
800
  }
723
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
+
724
848
  async createBlockProposal(
725
849
  blockHeader: BlockHeader,
726
850
  indexWithinCheckpoint: IndexWithinCheckpoint,
@@ -730,11 +854,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
730
854
  proposerAddress: EthAddress | undefined,
731
855
  options: BlockProposalOptions = {},
732
856
  ): Promise<BlockProposal> {
733
- // TODO(palla/mbps): Prevent double proposals properly
734
- // if (this.previousProposal?.slotNumber === blockHeader.globalVariables.slotNumber) {
735
- // this.log.verbose(`Already made a proposal for the same slot, skipping proposal`);
736
- // return Promise.resolve(undefined);
737
- // }
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
+ }
738
870
 
739
871
  this.log.info(
740
872
  `Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
@@ -751,25 +883,42 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
751
883
  broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
752
884
  },
753
885
  );
754
- this.previousProposal = newProposal;
886
+ this.lastProposedBlock = newProposal;
755
887
  return newProposal;
756
888
  }
757
889
 
758
890
  async createCheckpointProposal(
759
891
  checkpointHeader: CheckpointHeader,
760
892
  archive: Fr,
893
+ feeAssetPriceModifier: bigint,
761
894
  lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
762
895
  proposerAddress: EthAddress | undefined,
763
896
  options: CheckpointProposalOptions = {},
764
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
+
765
911
  this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
766
- return await this.validationService.createCheckpointProposal(
912
+ const newProposal = await this.validationService.createCheckpointProposal(
767
913
  checkpointHeader,
768
914
  archive,
915
+ feeAssetPriceModifier,
769
916
  lastBlockInfo,
770
917
  proposerAddress,
771
918
  options,
772
919
  );
920
+ this.lastProposedCheckpoint = newProposal;
921
+ return newProposal;
773
922
  }
774
923
 
775
924
  async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
@@ -791,6 +940,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
791
940
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
792
941
  const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
793
942
 
943
+ if (!attestations) {
944
+ return [];
945
+ }
946
+
794
947
  // We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
795
948
  // other nodes can see that our validators did attest to this block proposal, and do not slash us
796
949
  // due to inactivity for missed attestations.