@aztec/validator-client 0.0.1-commit.d431d1c → 0.0.1-commit.e2b2873ed

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.
@@ -1,5 +1,6 @@
1
1
  import { BlockNumber } from '@aztec/foundation/branded-types';
2
2
  import { Fr } from '@aztec/foundation/curves/bn254';
3
+ import type { LoggerBindings } from '@aztec/foundation/log';
3
4
  import { getVKTreeRoot } from '@aztec/noir-protocol-circuits-types/vk-tree';
4
5
  import {
5
6
  AggregateTxValidator,
@@ -53,32 +54,41 @@ export function createValidatorForAcceptingTxs(
53
54
  blockNumber: BlockNumber;
54
55
  txsPermitted: boolean;
55
56
  },
57
+ bindings?: LoggerBindings,
56
58
  ): TxValidator<Tx> {
57
59
  const validators: TxValidator<Tx>[] = [
58
- new TxPermittedValidator(txsPermitted),
59
- new SizeTxValidator(),
60
- new DataTxValidator(),
61
- new MetadataTxValidator({
62
- l1ChainId: new Fr(l1ChainId),
63
- rollupVersion: new Fr(rollupVersion),
64
- protocolContractsHash,
65
- vkTreeRoot: getVKTreeRoot(),
66
- }),
67
- new TimestampTxValidator({
68
- timestamp,
69
- blockNumber,
70
- }),
71
- new DoubleSpendTxValidator(new NullifierCache(db)),
72
- new PhasesTxValidator(contractDataSource, setupAllowList, timestamp),
73
- new BlockHeaderTxValidator(new ArchiveCache(db)),
60
+ new TxPermittedValidator(txsPermitted, bindings),
61
+ new SizeTxValidator(bindings),
62
+ new DataTxValidator(bindings),
63
+ new MetadataTxValidator(
64
+ {
65
+ l1ChainId: new Fr(l1ChainId),
66
+ rollupVersion: new Fr(rollupVersion),
67
+ protocolContractsHash,
68
+ vkTreeRoot: getVKTreeRoot(),
69
+ },
70
+ bindings,
71
+ ),
72
+ new TimestampTxValidator(
73
+ {
74
+ timestamp,
75
+ blockNumber,
76
+ },
77
+ bindings,
78
+ ),
79
+ new DoubleSpendTxValidator(new NullifierCache(db), bindings),
80
+ new PhasesTxValidator(contractDataSource, setupAllowList, timestamp, bindings),
81
+ new BlockHeaderTxValidator(new ArchiveCache(db), bindings),
74
82
  ];
75
83
 
76
84
  if (!skipFeeEnforcement) {
77
- validators.push(new GasTxValidator(new DatabasePublicStateSource(db), ProtocolContractAddress.FeeJuice, gasFees));
85
+ validators.push(
86
+ new GasTxValidator(new DatabasePublicStateSource(db), ProtocolContractAddress.FeeJuice, gasFees, bindings),
87
+ );
78
88
  }
79
89
 
80
90
  if (verifier) {
81
- validators.push(new TxProofValidator(verifier));
91
+ validators.push(new TxProofValidator(verifier, bindings));
82
92
  }
83
93
 
84
94
  return new AggregateTxValidator(...validators);
@@ -89,6 +99,7 @@ export function createValidatorForBlockBuilding(
89
99
  contractDataSource: ContractDataSource,
90
100
  globalVariables: GlobalVariables,
91
101
  setupAllowList: AllowedElement[],
102
+ bindings?: LoggerBindings,
92
103
  ): PublicProcessorValidator {
93
104
  const nullifierCache = new NullifierCache(db);
94
105
  const archiveCache = new ArchiveCache(db);
@@ -102,6 +113,7 @@ export function createValidatorForBlockBuilding(
102
113
  contractDataSource,
103
114
  globalVariables,
104
115
  setupAllowList,
116
+ bindings,
105
117
  ),
106
118
  nullifierCache,
107
119
  };
@@ -114,22 +126,29 @@ function preprocessValidator(
114
126
  contractDataSource: ContractDataSource,
115
127
  globalVariables: GlobalVariables,
116
128
  setupAllowList: AllowedElement[],
129
+ bindings?: LoggerBindings,
117
130
  ): TxValidator<Tx> {
118
131
  // We don't include the TxProofValidator nor the DataTxValidator here because they are already checked by the time we get to block building.
119
132
  return new AggregateTxValidator(
120
- new MetadataTxValidator({
121
- l1ChainId: globalVariables.chainId,
122
- rollupVersion: globalVariables.version,
123
- protocolContractsHash,
124
- vkTreeRoot: getVKTreeRoot(),
125
- }),
126
- new TimestampTxValidator({
127
- timestamp: globalVariables.timestamp,
128
- blockNumber: globalVariables.blockNumber,
129
- }),
130
- new DoubleSpendTxValidator(nullifierCache),
131
- new PhasesTxValidator(contractDataSource, setupAllowList, globalVariables.timestamp),
132
- new GasTxValidator(publicStateSource, ProtocolContractAddress.FeeJuice, globalVariables.gasFees),
133
- new BlockHeaderTxValidator(archiveCache),
133
+ new MetadataTxValidator(
134
+ {
135
+ l1ChainId: globalVariables.chainId,
136
+ rollupVersion: globalVariables.version,
137
+ protocolContractsHash,
138
+ vkTreeRoot: getVKTreeRoot(),
139
+ },
140
+ bindings,
141
+ ),
142
+ new TimestampTxValidator(
143
+ {
144
+ timestamp: globalVariables.timestamp,
145
+ blockNumber: globalVariables.blockNumber,
146
+ },
147
+ bindings,
148
+ ),
149
+ new DoubleSpendTxValidator(nullifierCache, bindings),
150
+ new PhasesTxValidator(contractDataSource, setupAllowList, globalVariables.timestamp, bindings),
151
+ new GasTxValidator(publicStateSource, ProtocolContractAddress.FeeJuice, globalVariables.gasFees, bindings),
152
+ new BlockHeaderTxValidator(archiveCache, bindings),
134
153
  );
135
154
  }
package/src/validator.ts CHANGED
@@ -18,27 +18,28 @@ import { RunningPromise } from '@aztec/foundation/running-promise';
18
18
  import { sleep } from '@aztec/foundation/sleep';
19
19
  import { DateProvider } from '@aztec/foundation/timer';
20
20
  import type { KeystoreManager } from '@aztec/node-keystore';
21
- import type { P2P, PeerId, TxProvider } from '@aztec/p2p';
21
+ import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
22
22
  import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
23
23
  import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
24
24
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
25
- import type { CommitteeAttestationsAndSigners, L2BlockNew, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
25
+ import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
26
26
  import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
27
27
  import type {
28
28
  CreateCheckpointProposalLastBlockData,
29
+ ITxProvider,
29
30
  Validator,
30
31
  ValidatorClientFullConfig,
31
32
  WorldStateSynchronizer,
32
33
  } 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,
34
+ import { type L1ToL2MessageSource, accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging';
35
+ import {
36
+ type BlockProposal,
37
+ type BlockProposalOptions,
38
+ type CheckpointAttestation,
39
+ CheckpointProposal,
40
+ type CheckpointProposalCore,
41
+ type CheckpointProposalOptions,
40
42
  } from '@aztec/stdlib/p2p';
41
- import { CheckpointProposal } from '@aztec/stdlib/p2p';
42
43
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
43
44
  import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
44
45
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
@@ -79,18 +80,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
79
80
  // Whether it has already registered handlers on the p2p client
80
81
  private hasRegisteredHandlers = false;
81
82
 
82
- // Used to check if we are sending the same proposal twice
83
- private previousProposal?: BlockProposal;
83
+ /** Tracks the last block proposal we created, to detect duplicate proposal attempts. */
84
+ private lastProposedBlock?: BlockProposal;
85
+
86
+ /** Tracks the last checkpoint proposal we created. */
87
+ private lastProposedCheckpoint?: CheckpointProposal;
84
88
 
85
89
  private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
86
90
  private epochCacheUpdateLoop: RunningPromise;
87
91
 
88
92
  private proposersOfInvalidBlocks: Set<string> = new Set();
89
93
 
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();
94
+ /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
95
+ private lastAttestedProposal?: CheckpointProposalCore;
94
96
 
95
97
  protected constructor(
96
98
  private keyStore: ExtendedValidatorKeyStore,
@@ -184,7 +186,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
184
186
  p2pClient: P2P,
185
187
  blockSource: L2BlockSource & L2BlockSink,
186
188
  l1ToL2MessageSource: L1ToL2MessageSource,
187
- txProvider: TxProvider,
189
+ txProvider: ITxProvider,
188
190
  keyStoreManager: KeystoreManager,
189
191
  blobClient: BlobClientInterface,
190
192
  dateProvider: DateProvider = new DateProvider(),
@@ -313,6 +315,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
313
315
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
314
316
  this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
315
317
 
318
+ // Duplicate proposal handler - triggers slashing for equivocation
319
+ this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
320
+ this.handleDuplicateProposal(info);
321
+ });
322
+
323
+ // Duplicate attestation handler - triggers slashing for attestation equivocation
324
+ this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
325
+ this.handleDuplicateAttestation(info);
326
+ });
327
+
316
328
  const myAddresses = this.getValidatorAddresses();
317
329
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
318
330
 
@@ -340,6 +352,15 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
340
352
  return false;
341
353
  }
342
354
 
355
+ // Ignore proposals from ourselves (may happen in HA setups)
356
+ if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
357
+ this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
358
+ proposer: proposer.toString(),
359
+ slotNumber,
360
+ });
361
+ return false;
362
+ }
363
+
343
364
  // Check if we're in the committee (for metrics purposes)
344
365
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
345
366
  const partOfCommittee = inCommittee.length > 0;
@@ -413,10 +434,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
413
434
  return false;
414
435
  }
415
436
 
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
437
  return true;
421
438
  }
422
439
 
@@ -445,6 +462,15 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
445
462
  return undefined;
446
463
  }
447
464
 
465
+ // Ignore proposals from ourselves (may happen in HA setups)
466
+ if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
467
+ this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
468
+ proposer: proposer.toString(),
469
+ slotNumber,
470
+ });
471
+ return undefined;
472
+ }
473
+
448
474
  // Check that I have any address in current committee before attesting
449
475
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
450
476
  const partOfCommittee = inCommittee.length > 0;
@@ -461,17 +487,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
461
487
  fishermanMode: this.config.fishermanMode || false,
462
488
  });
463
489
 
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
490
  // 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);
491
+ if (this.config.skipCheckpointProposalValidation) {
492
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
475
493
  } else {
476
494
  const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
477
495
  if (!validationResult.isValid) {
@@ -526,15 +544,45 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
526
544
  return undefined;
527
545
  }
528
546
 
529
- return this.createCheckpointAttestationsFromProposal(proposal, attestors);
547
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
548
+ }
549
+
550
+ /**
551
+ * Checks if we should attest to a slot based on equivocation prevention rules.
552
+ * @returns true if we should attest, false if we should skip
553
+ */
554
+ private shouldAttestToSlot(slotNumber: SlotNumber): boolean {
555
+ // If attestToEquivocatedProposals is true, always allow
556
+ if (this.config.attestToEquivocatedProposals) {
557
+ return true;
558
+ }
559
+
560
+ // Check if incoming slot is strictly greater than last attested
561
+ if (this.lastAttestedProposal && slotNumber <= this.lastAttestedProposal.slotNumber) {
562
+ this.log.warn(
563
+ `Refusing to process a proposal for slot ${slotNumber} given we already attested to a proposal for slot ${this.lastAttestedProposal.slotNumber}`,
564
+ );
565
+ return false;
566
+ }
567
+
568
+ return true;
530
569
  }
531
570
 
532
571
  private async createCheckpointAttestationsFromProposal(
533
572
  proposal: CheckpointProposalCore,
534
573
  attestors: EthAddress[] = [],
535
- ): Promise<CheckpointAttestation[]> {
574
+ ): Promise<CheckpointAttestation[] | undefined> {
575
+ // Equivocation check: must happen right before signing to minimize the race window
576
+ if (!this.shouldAttestToSlot(proposal.slotNumber)) {
577
+ return undefined;
578
+ }
579
+
536
580
  const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
537
- await this.p2pClient.addCheckpointAttestations(attestations);
581
+
582
+ // Track the proposal we attested to (to prevent equivocation)
583
+ this.lastAttestedProposal = proposal;
584
+
585
+ await this.p2pClient.addOwnCheckpointAttestations(attestations);
538
586
  return attestations;
539
587
  }
540
588
 
@@ -547,7 +595,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
547
595
  proposalInfo: LogData,
548
596
  ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
549
597
  const slot = proposal.slotNumber;
550
- const timeoutSeconds = 10;
598
+ const timeoutSeconds = 10; // TODO(palla/mbps): This should map to the timetable settings
551
599
 
552
600
  // Wait for last block to sync by archive
553
601
  let lastBlockHeader: BlockHeader | undefined;
@@ -617,6 +665,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
617
665
  previousCheckpointOutHashes,
618
666
  fork,
619
667
  blocks,
668
+ this.log.getBindings(),
620
669
  );
621
670
 
622
671
  // Complete the checkpoint to get computed values
@@ -642,13 +691,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
642
691
  return { isValid: false, reason: 'archive_mismatch' };
643
692
  }
644
693
 
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)) {
694
+ // Check that the accumulated epoch out hash matches the value in the proposal.
695
+ // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
696
+ const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
697
+ const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
698
+ const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
699
+ if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
649
700
  this.log.warn(`Epoch out hash mismatch`, {
650
- proposalOutHash: proposalOutHash.toString(),
651
- computedOutHash: computedOutHash.toString(),
701
+ proposalEpochOutHash: proposalEpochOutHash.toString(),
702
+ computedEpochOutHash: computedEpochOutHash.toString(),
703
+ checkpointOutHash: checkpointOutHash.toString(),
704
+ previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
652
705
  ...proposalInfo,
653
706
  });
654
707
  return { isValid: false, reason: 'out_hash_mismatch' };
@@ -664,7 +717,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
664
717
  /**
665
718
  * Extract checkpoint global variables from a block.
666
719
  */
667
- private extractCheckpointConstants(block: L2BlockNew): CheckpointGlobalVariables {
720
+ private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
668
721
  const gv = block.header.globalVariables;
669
722
  return {
670
723
  chainId: gv.chainId,
@@ -732,6 +785,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
732
785
  ]);
733
786
  }
734
787
 
788
+ /**
789
+ * Handle detection of a duplicate proposal (equivocation).
790
+ * Emits a slash event when a proposer sends multiple proposals for the same position.
791
+ */
792
+ private handleDuplicateProposal(info: DuplicateProposalInfo): void {
793
+ const { slot, proposer, type } = info;
794
+
795
+ this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
796
+ proposer: proposer.toString(),
797
+ slot,
798
+ type,
799
+ });
800
+
801
+ // Emit slash event
802
+ this.emit(WANT_TO_SLASH_EVENT, [
803
+ {
804
+ validator: proposer,
805
+ amount: this.config.slashDuplicateProposalPenalty,
806
+ offenseType: OffenseType.DUPLICATE_PROPOSAL,
807
+ epochOrSlot: BigInt(slot),
808
+ },
809
+ ]);
810
+ }
811
+
812
+ /**
813
+ * Handle detection of a duplicate attestation (equivocation).
814
+ * Emits a slash event when an attester signs attestations for different proposals at the same slot.
815
+ */
816
+ private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
817
+ const { slot, attester } = info;
818
+
819
+ this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
820
+ attester: attester.toString(),
821
+ slot,
822
+ });
823
+
824
+ this.emit(WANT_TO_SLASH_EVENT, [
825
+ {
826
+ validator: attester,
827
+ amount: this.config.slashDuplicateAttestationPenalty,
828
+ offenseType: OffenseType.DUPLICATE_ATTESTATION,
829
+ epochOrSlot: BigInt(slot),
830
+ },
831
+ ]);
832
+ }
833
+
735
834
  async createBlockProposal(
736
835
  blockHeader: BlockHeader,
737
836
  indexWithinCheckpoint: IndexWithinCheckpoint,
@@ -739,13 +838,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
739
838
  archive: Fr,
740
839
  txs: Tx[],
741
840
  proposerAddress: EthAddress | undefined,
742
- options: BlockProposalOptions,
841
+ options: BlockProposalOptions = {},
743
842
  ): 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
- // }
843
+ // Validate that we're not creating a proposal for an older or equal position
844
+ if (this.lastProposedBlock) {
845
+ const lastSlot = this.lastProposedBlock.slotNumber;
846
+ const lastIndex = this.lastProposedBlock.indexWithinCheckpoint;
847
+ const newSlot = blockHeader.globalVariables.slotNumber;
848
+
849
+ if (newSlot < lastSlot || (newSlot === lastSlot && indexWithinCheckpoint <= lastIndex)) {
850
+ throw new Error(
851
+ `Cannot create block proposal for slot ${newSlot} index ${indexWithinCheckpoint}: ` +
852
+ `already proposed block for slot ${lastSlot} index ${lastIndex}`,
853
+ );
854
+ }
855
+ }
749
856
 
750
857
  this.log.info(
751
858
  `Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
@@ -762,7 +869,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
762
869
  broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
763
870
  },
764
871
  );
765
- this.previousProposal = newProposal;
872
+ this.lastProposedBlock = newProposal;
766
873
  return newProposal;
767
874
  }
768
875
 
@@ -771,16 +878,31 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
771
878
  archive: Fr,
772
879
  lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
773
880
  proposerAddress: EthAddress | undefined,
774
- options: CheckpointProposalOptions,
881
+ options: CheckpointProposalOptions = {},
775
882
  ): Promise<CheckpointProposal> {
883
+ // Validate that we're not creating a proposal for an older or equal slot
884
+ if (this.lastProposedCheckpoint) {
885
+ const lastSlot = this.lastProposedCheckpoint.slotNumber;
886
+ const newSlot = checkpointHeader.slotNumber;
887
+
888
+ if (newSlot <= lastSlot) {
889
+ throw new Error(
890
+ `Cannot create checkpoint proposal for slot ${newSlot}: ` +
891
+ `already proposed checkpoint for slot ${lastSlot}`,
892
+ );
893
+ }
894
+ }
895
+
776
896
  this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
777
- return await this.validationService.createCheckpointProposal(
897
+ const newProposal = await this.validationService.createCheckpointProposal(
778
898
  checkpointHeader,
779
899
  archive,
780
900
  lastBlockInfo,
781
901
  proposerAddress,
782
902
  options,
783
903
  );
904
+ this.lastProposedCheckpoint = newProposal;
905
+ return newProposal;
784
906
  }
785
907
 
786
908
  async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
@@ -802,6 +924,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
802
924
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
803
925
  const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
804
926
 
927
+ if (!attestations) {
928
+ return [];
929
+ }
930
+
805
931
  // We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
806
932
  // other nodes can see that our validators did attest to this block proposal, and do not slash us
807
933
  // due to inactivity for missed attestations.