@aztec/validator-client 0.0.1-commit.fffb133c → 0.0.1-dev

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 (53) hide show
  1. package/README.md +62 -18
  2. package/dest/checkpoint_builder.d.ts +26 -14
  3. package/dest/checkpoint_builder.d.ts.map +1 -1
  4. package/dest/checkpoint_builder.js +136 -45
  5. package/dest/config.d.ts +1 -1
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +26 -3
  8. package/dest/duties/validation_service.d.ts +3 -4
  9. package/dest/duties/validation_service.d.ts.map +1 -1
  10. package/dest/duties/validation_service.js +11 -22
  11. package/dest/factory.d.ts +7 -4
  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 +1 -1
  18. package/dest/key_store/ha_key_store.d.ts.map +1 -1
  19. package/dest/key_store/ha_key_store.js +3 -3
  20. package/dest/metrics.d.ts +12 -3
  21. package/dest/metrics.d.ts.map +1 -1
  22. package/dest/metrics.js +46 -5
  23. package/dest/proposal_handler.d.ts +94 -0
  24. package/dest/proposal_handler.d.ts.map +1 -0
  25. package/dest/{block_proposal_handler.js → proposal_handler.js} +376 -69
  26. package/dest/validator.d.ts +37 -24
  27. package/dest/validator.d.ts.map +1 -1
  28. package/dest/validator.js +167 -215
  29. package/package.json +19 -17
  30. package/src/checkpoint_builder.ts +183 -50
  31. package/src/config.ts +26 -3
  32. package/src/duties/validation_service.ts +18 -24
  33. package/src/factory.ts +9 -3
  34. package/src/index.ts +1 -2
  35. package/src/key_store/ha_key_store.ts +3 -3
  36. package/src/metrics.ts +63 -6
  37. package/src/proposal_handler.ts +903 -0
  38. package/src/validator.ts +228 -248
  39. package/dest/block_proposal_handler.d.ts +0 -64
  40. package/dest/block_proposal_handler.d.ts.map +0 -1
  41. package/dest/tx_validator/index.d.ts +0 -3
  42. package/dest/tx_validator/index.d.ts.map +0 -1
  43. package/dest/tx_validator/index.js +0 -2
  44. package/dest/tx_validator/nullifier_cache.d.ts +0 -14
  45. package/dest/tx_validator/nullifier_cache.d.ts.map +0 -1
  46. package/dest/tx_validator/nullifier_cache.js +0 -24
  47. package/dest/tx_validator/tx_validator_factory.d.ts +0 -18
  48. package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
  49. package/dest/tx_validator/tx_validator_factory.js +0 -54
  50. package/src/block_proposal_handler.ts +0 -554
  51. package/src/tx_validator/index.ts +0 -2
  52. package/src/tx_validator/nullifier_cache.ts +0 -30
  53. package/src/tx_validator/tx_validator_factory.ts +0 -135
package/src/validator.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { BlobClientInterface } from '@aztec/blob-client/client';
2
- import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib';
3
2
  import type { EpochCache } from '@aztec/epoch-cache';
4
3
  import {
5
4
  BlockNumber,
@@ -9,53 +8,52 @@ import {
9
8
  SlotNumber,
10
9
  } from '@aztec/foundation/branded-types';
11
10
  import { Fr } from '@aztec/foundation/curves/bn254';
12
- import { TimeoutError } from '@aztec/foundation/error';
13
11
  import type { EthAddress } from '@aztec/foundation/eth-address';
14
12
  import type { Signature } from '@aztec/foundation/eth-signature';
15
- import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
16
- import { retryUntil } from '@aztec/foundation/retry';
13
+ import { type Logger, createLogger } from '@aztec/foundation/log';
17
14
  import { RunningPromise } from '@aztec/foundation/running-promise';
18
15
  import { sleep } from '@aztec/foundation/sleep';
19
16
  import { DateProvider } from '@aztec/foundation/timer';
20
17
  import type { KeystoreManager } from '@aztec/node-keystore';
21
- import type { P2P, PeerId, TxProvider } from '@aztec/p2p';
18
+ import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
22
19
  import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
23
20
  import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
24
21
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
25
- import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
22
+ import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
26
23
  import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
27
24
  import type {
28
- CreateCheckpointProposalLastBlockData,
25
+ ITxProvider,
29
26
  Validator,
30
27
  ValidatorClientFullConfig,
31
28
  WorldStateSynchronizer,
32
29
  } from '@aztec/stdlib/interfaces/server';
33
30
  import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
34
- import type {
35
- BlockProposal,
36
- BlockProposalOptions,
37
- CheckpointAttestation,
38
- CheckpointProposalCore,
39
- CheckpointProposalOptions,
31
+ import {
32
+ type BlockProposal,
33
+ type BlockProposalOptions,
34
+ type CheckpointAttestation,
35
+ CheckpointProposal,
36
+ type CheckpointProposalCore,
37
+ type CheckpointProposalOptions,
40
38
  } from '@aztec/stdlib/p2p';
41
- import { CheckpointProposal } from '@aztec/stdlib/p2p';
42
39
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
43
- import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
40
+ import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
44
41
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
45
42
  import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
46
- import { createHASigner } from '@aztec/validator-ha-signer/factory';
47
- import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
43
+ import { createHASigner, createSignerFromSharedDb } from '@aztec/validator-ha-signer/factory';
44
+ import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
45
+ import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
48
46
 
49
47
  import { EventEmitter } from 'events';
50
48
  import type { TypedDataDefinition } from 'viem';
51
49
 
52
- import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
53
50
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
54
51
  import { ValidationService } from './duties/validation_service.js';
55
52
  import { HAKeyStore } from './key_store/ha_key_store.js';
56
53
  import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
57
54
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
58
55
  import { ValidatorMetrics } from './metrics.js';
56
+ import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.js';
59
57
 
60
58
  // We maintain a set of proposers who have proposed invalid blocks.
61
59
  // Just cap the set to avoid unbounded growth.
@@ -75,34 +73,33 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
75
73
  private validationService: ValidationService;
76
74
  private metrics: ValidatorMetrics;
77
75
  private log: Logger;
78
-
79
76
  // Whether it has already registered handlers on the p2p client
80
77
  private hasRegisteredHandlers = false;
81
78
 
82
- // Used to check if we are sending the same proposal twice
83
- private previousProposal?: BlockProposal;
79
+ /** Tracks the last block proposal we created, to detect duplicate proposal attempts. */
80
+ private lastProposedBlock?: BlockProposal;
81
+
82
+ /** Tracks the last checkpoint proposal we created. */
83
+ private lastProposedCheckpoint?: CheckpointProposal;
84
84
 
85
85
  private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
86
86
  private epochCacheUpdateLoop: RunningPromise;
87
+ /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
88
+ private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
87
89
 
88
90
  private proposersOfInvalidBlocks: Set<string> = new Set();
89
91
 
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();
92
+ /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
93
+ private lastAttestedProposal?: CheckpointProposalCore;
94
94
 
95
95
  protected constructor(
96
96
  private keyStore: ExtendedValidatorKeyStore,
97
97
  private epochCache: EpochCache,
98
98
  private p2pClient: P2P,
99
- private blockProposalHandler: BlockProposalHandler,
100
- private blockSource: L2BlockSource,
101
- private checkpointsBuilder: FullNodeCheckpointsBuilder,
102
- private worldState: WorldStateSynchronizer,
103
- private l1ToL2MessageSource: L1ToL2MessageSource,
99
+ private proposalHandler: ProposalHandler,
104
100
  private config: ValidatorClientFullConfig,
105
101
  private blobClient: BlobClientInterface,
102
+ private haSigner: ValidatorHASigner | undefined,
106
103
  private dateProvider: DateProvider = new DateProvider(),
107
104
  telemetry: TelemetryClient = getTelemetryClient(),
108
105
  log = createLogger('validator'),
@@ -156,6 +153,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
156
153
  this.log.trace(`No committee found for slot`);
157
154
  return;
158
155
  }
156
+ this.metrics.setCurrentEpoch(epoch);
159
157
  if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
160
158
  const me = this.getValidatorAddresses();
161
159
  const committeeSet = new Set(committee.map(v => v.toString()));
@@ -184,17 +182,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
184
182
  p2pClient: P2P,
185
183
  blockSource: L2BlockSource & L2BlockSink,
186
184
  l1ToL2MessageSource: L1ToL2MessageSource,
187
- txProvider: TxProvider,
185
+ txProvider: ITxProvider,
188
186
  keyStoreManager: KeystoreManager,
189
187
  blobClient: BlobClientInterface,
190
188
  dateProvider: DateProvider = new DateProvider(),
191
189
  telemetry: TelemetryClient = getTelemetryClient(),
190
+ slashingProtectionDb?: SlashingProtectionDatabase,
192
191
  ) {
193
192
  const metrics = new ValidatorMetrics(telemetry);
194
193
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
195
194
  txsPermitted: !config.disableTransactions,
195
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
196
196
  });
197
- const blockProposalHandler = new BlockProposalHandler(
197
+ const proposalHandler = new ProposalHandler(
198
198
  checkpointsBuilder,
199
199
  worldState,
200
200
  blockSource,
@@ -203,33 +203,39 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
203
203
  blockProposalValidator,
204
204
  epochCache,
205
205
  config,
206
+ blobClient,
206
207
  metrics,
207
208
  dateProvider,
208
209
  telemetry,
209
210
  );
210
211
 
211
- let validatorKeyStore: ExtendedValidatorKeyStore = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
212
- if (config.haSigningEnabled) {
212
+ const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
213
+ let validatorKeyStore: ExtendedValidatorKeyStore = nodeKeystoreAdapter;
214
+ let haSigner: ValidatorHASigner | undefined;
215
+ if (slashingProtectionDb) {
216
+ // Shared database mode: use a pre-existing database (e.g. for testing HA setups).
217
+ const { signer } = createSignerFromSharedDb(slashingProtectionDb, config);
218
+ haSigner = signer;
219
+ validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
220
+ } else if (config.haSigningEnabled) {
213
221
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
214
222
  const haConfig = {
215
223
  ...config,
216
224
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
217
225
  };
218
226
  const { signer } = await createHASigner(haConfig);
219
- validatorKeyStore = new HAKeyStore(validatorKeyStore, signer);
227
+ haSigner = signer;
228
+ validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
220
229
  }
221
230
 
222
231
  const validator = new ValidatorClient(
223
232
  validatorKeyStore,
224
233
  epochCache,
225
234
  p2pClient,
226
- blockProposalHandler,
227
- blockSource,
228
- checkpointsBuilder,
229
- worldState,
230
- l1ToL2MessageSource,
235
+ proposalHandler,
231
236
  config,
232
237
  blobClient,
238
+ haSigner,
233
239
  dateProvider,
234
240
  telemetry,
235
241
  );
@@ -243,8 +249,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
243
249
  .filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
244
250
  }
245
251
 
246
- public getBlockProposalHandler() {
247
- return this.blockProposalHandler;
252
+ public getProposalHandler() {
253
+ return this.proposalHandler;
248
254
  }
249
255
 
250
256
  public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
@@ -267,6 +273,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
267
273
  this.config = { ...this.config, ...config };
268
274
  }
269
275
 
276
+ public reloadKeystore(newManager: KeystoreManager): void {
277
+ if (this.config.haSigningEnabled && !this.haSigner) {
278
+ this.log.warn(
279
+ 'HA signing is enabled in config but was not initialized at startup. ' +
280
+ 'Restart the node to enable HA signing.',
281
+ );
282
+ } else if (!this.config.haSigningEnabled && this.haSigner) {
283
+ this.log.warn(
284
+ 'HA signing was disabled via config update but the HA signer is still active. ' +
285
+ 'Restart the node to fully disable HA signing.',
286
+ );
287
+ }
288
+
289
+ const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
290
+ if (this.haSigner) {
291
+ this.keyStore = new HAKeyStore(newAdapter, this.haSigner);
292
+ } else {
293
+ this.keyStore = newAdapter;
294
+ }
295
+ this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
296
+ }
297
+
270
298
  public async start() {
271
299
  if (this.epochCacheUpdateLoop.isRunning()) {
272
300
  this.log.warn(`Validator client already started`);
@@ -313,6 +341,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
313
341
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
314
342
  this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
315
343
 
344
+ // Duplicate proposal handler - triggers slashing for equivocation
345
+ this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
346
+ this.handleDuplicateProposal(info);
347
+ });
348
+
349
+ // Duplicate attestation handler - triggers slashing for attestation equivocation
350
+ this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
351
+ this.handleDuplicateAttestation(info);
352
+ });
353
+
316
354
  const myAddresses = this.getValidatorAddresses();
317
355
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
318
356
 
@@ -340,6 +378,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
340
378
  return false;
341
379
  }
342
380
 
381
+ // Log self-proposals from HA peers (same validator key on different nodes)
382
+ if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
383
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
384
+ proposer: proposer.toString(),
385
+ slotNumber,
386
+ });
387
+ }
388
+
343
389
  // Check if we're in the committee (for metrics purposes)
344
390
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
345
391
  const partOfCommittee = inCommittee.length > 0;
@@ -362,16 +408,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
362
408
  alwaysReexecuteBlockProposals ||
363
409
  this.blobClient.canUpload();
364
410
 
365
- const validationResult = await this.blockProposalHandler.handleBlockProposal(
411
+ const validationResult = await this.proposalHandler.handleBlockProposal(
366
412
  proposal,
367
413
  proposalSender,
368
414
  !!shouldReexecute && !escapeHatchOpen,
369
415
  );
370
416
 
371
417
  if (!validationResult.isValid) {
372
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
373
-
374
418
  const reason = validationResult.reason || 'unknown';
419
+
420
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
421
+
375
422
  // Classify failure reason: bad proposal vs node issue
376
423
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
377
424
  'invalid_proposal',
@@ -413,10 +460,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
413
460
  return false;
414
461
  }
415
462
 
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
463
  return true;
421
464
  }
422
465
 
@@ -439,52 +482,40 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
439
482
  return undefined;
440
483
  }
441
484
 
442
- // Reject proposals with invalid signatures
443
- if (!proposer) {
444
- this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
485
+ // Ignore proposals from ourselves (may happen in HA setups)
486
+ if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
487
+ this.log.debug(`Ignoring block proposal from self for slot ${slotNumber}`, {
488
+ proposer: proposer.toString(),
489
+ slotNumber,
490
+ });
445
491
  return undefined;
446
492
  }
447
493
 
448
- // Check that I have any address in current committee before attesting
494
+ // Check that I have any address in the committee where this checkpoint will land before attesting
449
495
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
450
496
  const partOfCommittee = inCommittee.length > 0;
451
497
 
452
498
  const proposalInfo = {
453
499
  slotNumber,
454
500
  archive: proposal.archive.toString(),
455
- proposer: proposer.toString(),
456
- txCount: proposal.txHashes.length,
501
+ proposer: proposer?.toString(),
457
502
  };
458
503
  this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
459
504
  ...proposalInfo,
460
- txHashes: proposal.txHashes.map(t => t.toString()),
461
505
  fishermanMode: this.config.fishermanMode || false,
462
506
  });
463
507
 
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
- // 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);
508
+ // Validate the checkpoint proposal and upload blobs (unless skipCheckpointProposalValidation is set)
509
+ if (this.config.skipCheckpointProposalValidation) {
510
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
475
511
  } else {
476
- const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
512
+ const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
477
513
  if (!validationResult.isValid) {
478
514
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
479
515
  return undefined;
480
516
  }
481
517
  }
482
518
 
483
- // Upload blobs to filestore if we can (fire and forget)
484
- if (this.blobClient.canUpload()) {
485
- void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
486
- }
487
-
488
519
  // Check that I have any address in current committee before attesting
489
520
  // In fisherman mode, we still create attestations for validation even if not in committee
490
521
  if (!partOfCommittee && !this.config.fishermanMode) {
@@ -501,6 +532,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
501
532
 
502
533
  this.metrics.incSuccessfulAttestations(inCommittee.length);
503
534
 
535
+ // Track epoch participation per attester: count each (attester, epoch) pair at most once
536
+ const proposalEpoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants());
537
+ for (const attester of inCommittee) {
538
+ const key = attester.toString();
539
+ const lastEpoch = this.lastAttestedEpochByAttester.get(key);
540
+ if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
541
+ this.lastAttestedEpochByAttester.set(key, proposalEpoch);
542
+ this.metrics.incAttestedEpochCount(attester);
543
+ }
544
+ }
545
+
504
546
  // Determine which validators should attest
505
547
  let attestors: EthAddress[];
506
548
  if (partOfCommittee) {
@@ -526,183 +568,46 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
526
568
  return undefined;
527
569
  }
528
570
 
529
- return this.createCheckpointAttestationsFromProposal(proposal, attestors);
530
- }
531
-
532
- private async createCheckpointAttestationsFromProposal(
533
- proposal: CheckpointProposalCore,
534
- attestors: EthAddress[] = [],
535
- ): Promise<CheckpointAttestation[]> {
536
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
537
- await this.p2pClient.addCheckpointAttestations(attestations);
538
- return attestations;
571
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
539
572
  }
540
573
 
541
574
  /**
542
- * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
543
- * @returns Validation result with isValid flag and reason if invalid.
575
+ * Checks if we should attest to a slot based on equivocation prevention rules.
576
+ * @returns true if we should attest, false if we should skip
544
577
  */
545
- private async validateCheckpointProposal(
546
- proposal: CheckpointProposalCore,
547
- proposalInfo: LogData,
548
- ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
549
- const slot = proposal.slotNumber;
550
- const timeoutSeconds = 10;
551
-
552
- // Wait for last block to sync by archive
553
- let lastBlockHeader: BlockHeader | undefined;
554
- try {
555
- lastBlockHeader = await retryUntil(
556
- async () => {
557
- await this.blockSource.syncImmediate();
558
- return this.blockSource.getBlockHeaderByArchive(proposal.archive);
559
- },
560
- `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
561
- timeoutSeconds,
562
- 0.5,
563
- );
564
- } catch (err) {
565
- if (err instanceof TimeoutError) {
566
- this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
567
- return { isValid: false, reason: 'last_block_not_found' };
568
- }
569
- this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
570
- return { isValid: false, reason: 'block_fetch_error' };
571
- }
572
-
573
- if (!lastBlockHeader) {
574
- this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
575
- return { isValid: false, reason: 'last_block_not_found' };
578
+ private shouldAttestToSlot(slotNumber: SlotNumber): boolean {
579
+ // If attestToEquivocatedProposals is true, always allow
580
+ if (this.config.attestToEquivocatedProposals) {
581
+ return true;
576
582
  }
577
583
 
578
- // Get all full blocks for the slot and checkpoint
579
- const blocks = await this.blockSource.getBlocksForSlot(slot);
580
- if (blocks.length === 0) {
581
- this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
582
- return { isValid: false, reason: 'no_blocks_for_slot' };
583
- }
584
-
585
- this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
586
- ...proposalInfo,
587
- blockNumbers: blocks.map(b => b.number),
588
- });
589
-
590
- // Get checkpoint constants from first block
591
- const firstBlock = blocks[0];
592
- const constants = this.extractCheckpointConstants(firstBlock);
593
- const checkpointNumber = firstBlock.checkpointNumber;
594
-
595
- // Get L1-to-L2 messages for this checkpoint
596
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
597
-
598
- // Compute the previous checkpoint out hashes for the epoch.
599
- // TODO: There can be a more efficient way to get the previous checkpoint out hashes without having to fetch the
600
- // actual checkpoints and the blocks/txs in them.
601
- const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
602
- const previousCheckpoints = (await this.blockSource.getCheckpointsForEpoch(epoch))
603
- .filter(b => b.number < checkpointNumber)
604
- .sort((a, b) => a.number - b.number);
605
- const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
606
-
607
- // Fork world state at the block before the first block
608
- const parentBlockNumber = BlockNumber(firstBlock.number - 1);
609
- const fork = await this.worldState.fork(parentBlockNumber);
610
-
611
- try {
612
- // Create checkpoint builder with all existing blocks
613
- const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
614
- checkpointNumber,
615
- constants,
616
- l1ToL2Messages,
617
- previousCheckpointOutHashes,
618
- fork,
619
- blocks,
584
+ // Check if incoming slot is strictly greater than last attested
585
+ if (this.lastAttestedProposal && slotNumber <= this.lastAttestedProposal.slotNumber) {
586
+ this.log.warn(
587
+ `Refusing to process a proposal for slot ${slotNumber} given we already attested to a proposal for slot ${this.lastAttestedProposal.slotNumber}`,
620
588
  );
621
-
622
- // Complete the checkpoint to get computed values
623
- const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
624
-
625
- // Compare checkpoint header with proposal
626
- if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
627
- this.log.warn(`Checkpoint header mismatch`, {
628
- ...proposalInfo,
629
- computed: computedCheckpoint.header.toInspect(),
630
- proposal: proposal.checkpointHeader.toInspect(),
631
- });
632
- return { isValid: false, reason: 'checkpoint_header_mismatch' };
633
- }
634
-
635
- // Compare archive root with proposal
636
- if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
637
- this.log.warn(`Archive root mismatch`, {
638
- ...proposalInfo,
639
- computed: computedCheckpoint.archive.root.toString(),
640
- proposal: proposal.archive.toString(),
641
- });
642
- return { isValid: false, reason: 'archive_mismatch' };
643
- }
644
-
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)) {
649
- this.log.warn(`Epoch out hash mismatch`, {
650
- proposalOutHash: proposalOutHash.toString(),
651
- computedOutHash: computedOutHash.toString(),
652
- ...proposalInfo,
653
- });
654
- return { isValid: false, reason: 'out_hash_mismatch' };
655
- }
656
-
657
- this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
658
- return { isValid: true };
659
- } finally {
660
- await fork.close();
589
+ return false;
661
590
  }
662
- }
663
591
 
664
- /**
665
- * Extract checkpoint global variables from a block.
666
- */
667
- private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
668
- const gv = block.header.globalVariables;
669
- return {
670
- chainId: gv.chainId,
671
- version: gv.version,
672
- slotNumber: gv.slotNumber,
673
- coinbase: gv.coinbase,
674
- feeRecipient: gv.feeRecipient,
675
- gasFees: gv.gasFees,
676
- };
592
+ return true;
677
593
  }
678
594
 
679
- /**
680
- * Uploads blobs for a checkpoint to the filestore (fire and forget).
681
- */
682
- private async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
683
- try {
684
- const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
685
- if (!lastBlockHeader) {
686
- this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
687
- return;
688
- }
595
+ private async createCheckpointAttestationsFromProposal(
596
+ proposal: CheckpointProposalCore,
597
+ attestors: EthAddress[] = [],
598
+ ): Promise<CheckpointAttestation[] | undefined> {
599
+ // Equivocation check: must happen right before signing to minimize the race window
600
+ if (!this.shouldAttestToSlot(proposal.slotNumber)) {
601
+ return undefined;
602
+ }
689
603
 
690
- const blocks = await this.blockSource.getBlocksForSlot(proposal.slotNumber);
691
- if (blocks.length === 0) {
692
- this.log.warn(`No blocks found for blob upload`, proposalInfo);
693
- return;
694
- }
604
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
695
605
 
696
- const blobFields = blocks.flatMap(b => b.toBlobFields());
697
- const blobs: Blob[] = getBlobsPerL1Block(blobFields);
698
- await this.blobClient.sendBlobsToFilestore(blobs);
699
- this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
700
- ...proposalInfo,
701
- numBlobs: blobs.length,
702
- });
703
- } catch (err) {
704
- this.log.warn(`Failed to upload blobs for checkpoint: ${err}`, proposalInfo);
705
- }
606
+ // Track the proposal we attested to (to prevent equivocation)
607
+ this.lastAttestedProposal = proposal;
608
+
609
+ await this.p2pClient.addOwnCheckpointAttestations(attestations);
610
+ return attestations;
706
611
  }
707
612
 
708
613
  private slashInvalidBlock(proposal: BlockProposal) {
@@ -732,6 +637,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
732
637
  ]);
733
638
  }
734
639
 
640
+ /**
641
+ * Handle detection of a duplicate proposal (equivocation).
642
+ * Emits a slash event when a proposer sends multiple proposals for the same position.
643
+ */
644
+ private handleDuplicateProposal(info: DuplicateProposalInfo): void {
645
+ const { slot, proposer, type } = info;
646
+
647
+ this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
648
+ proposer: proposer.toString(),
649
+ slot,
650
+ type,
651
+ });
652
+
653
+ // Emit slash event
654
+ this.emit(WANT_TO_SLASH_EVENT, [
655
+ {
656
+ validator: proposer,
657
+ amount: this.config.slashDuplicateProposalPenalty,
658
+ offenseType: OffenseType.DUPLICATE_PROPOSAL,
659
+ epochOrSlot: BigInt(slot),
660
+ },
661
+ ]);
662
+ }
663
+
664
+ /**
665
+ * Handle detection of a duplicate attestation (equivocation).
666
+ * Emits a slash event when an attester signs attestations for different proposals at the same slot.
667
+ */
668
+ private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
669
+ const { slot, attester } = info;
670
+
671
+ this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
672
+ attester: attester.toString(),
673
+ slot,
674
+ });
675
+
676
+ this.emit(WANT_TO_SLASH_EVENT, [
677
+ {
678
+ validator: attester,
679
+ amount: this.config.slashDuplicateAttestationPenalty,
680
+ offenseType: OffenseType.DUPLICATE_ATTESTATION,
681
+ epochOrSlot: BigInt(slot),
682
+ },
683
+ ]);
684
+ }
685
+
735
686
  async createBlockProposal(
736
687
  blockHeader: BlockHeader,
737
688
  indexWithinCheckpoint: IndexWithinCheckpoint,
@@ -739,13 +690,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
739
690
  archive: Fr,
740
691
  txs: Tx[],
741
692
  proposerAddress: EthAddress | undefined,
742
- options: BlockProposalOptions,
693
+ options: BlockProposalOptions = {},
743
694
  ): 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
- // }
695
+ // Validate that we're not creating a proposal for an older or equal position
696
+ if (this.lastProposedBlock) {
697
+ const lastSlot = this.lastProposedBlock.slotNumber;
698
+ const lastIndex = this.lastProposedBlock.indexWithinCheckpoint;
699
+ const newSlot = blockHeader.globalVariables.slotNumber;
700
+
701
+ if (newSlot < lastSlot || (newSlot === lastSlot && indexWithinCheckpoint <= lastIndex)) {
702
+ throw new Error(
703
+ `Cannot create block proposal for slot ${newSlot} index ${indexWithinCheckpoint}: ` +
704
+ `already proposed block for slot ${lastSlot} index ${lastIndex}`,
705
+ );
706
+ }
707
+ }
749
708
 
750
709
  this.log.info(
751
710
  `Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
@@ -762,25 +721,42 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
762
721
  broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
763
722
  },
764
723
  );
765
- this.previousProposal = newProposal;
724
+ this.lastProposedBlock = newProposal;
766
725
  return newProposal;
767
726
  }
768
727
 
769
728
  async createCheckpointProposal(
770
729
  checkpointHeader: CheckpointHeader,
771
730
  archive: Fr,
772
- lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
731
+ feeAssetPriceModifier: bigint,
732
+ lastBlockProposal: BlockProposal | undefined,
773
733
  proposerAddress: EthAddress | undefined,
774
- options: CheckpointProposalOptions,
734
+ options: CheckpointProposalOptions = {},
775
735
  ): Promise<CheckpointProposal> {
736
+ // Validate that we're not creating a proposal for an older or equal slot
737
+ if (this.lastProposedCheckpoint) {
738
+ const lastSlot = this.lastProposedCheckpoint.slotNumber;
739
+ const newSlot = checkpointHeader.slotNumber;
740
+
741
+ if (newSlot <= lastSlot) {
742
+ throw new Error(
743
+ `Cannot create checkpoint proposal for slot ${newSlot}: ` +
744
+ `already proposed checkpoint for slot ${lastSlot}`,
745
+ );
746
+ }
747
+ }
748
+
776
749
  this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
777
- return await this.validationService.createCheckpointProposal(
750
+ const newProposal = await this.validationService.createCheckpointProposal(
778
751
  checkpointHeader,
779
752
  archive,
780
- lastBlockInfo,
753
+ feeAssetPriceModifier,
754
+ lastBlockProposal,
781
755
  proposerAddress,
782
756
  options,
783
757
  );
758
+ this.lastProposedCheckpoint = newProposal;
759
+ return newProposal;
784
760
  }
785
761
 
786
762
  async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
@@ -802,6 +778,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
802
778
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
803
779
  const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
804
780
 
781
+ if (!attestations) {
782
+ return [];
783
+ }
784
+
805
785
  // We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
806
786
  // other nodes can see that our validators did attest to this block proposal, and do not slash us
807
787
  // due to inactivity for missed attestations.