@aztec/validator-client 0.0.1-commit.1142ef1 → 0.0.1-commit.11bf3dd6e

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