@aztec/validator-client 0.0.1-commit.993d52e → 0.0.1-commit.9a89641

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 (44) hide show
  1. package/README.md +53 -11
  2. package/dest/checkpoint_builder.d.ts +29 -9
  3. package/dest/checkpoint_builder.d.ts.map +1 -1
  4. package/dest/checkpoint_builder.js +125 -37
  5. package/dest/config.d.ts +9 -3
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +41 -7
  8. package/dest/duties/validation_service.d.ts +12 -13
  9. package/dest/duties/validation_service.d.ts.map +1 -1
  10. package/dest/duties/validation_service.js +33 -45
  11. package/dest/factory.d.ts +10 -4
  12. package/dest/factory.d.ts.map +1 -1
  13. package/dest/factory.js +18 -6
  14. package/dest/index.d.ts +2 -2
  15. package/dest/index.d.ts.map +1 -1
  16. package/dest/index.js +1 -1
  17. package/dest/key_store/ha_key_store.js +1 -1
  18. package/dest/key_store/web3signer_key_store.d.ts +10 -2
  19. package/dest/key_store/web3signer_key_store.d.ts.map +1 -1
  20. package/dest/key_store/web3signer_key_store.js +32 -41
  21. package/dest/metrics.d.ts +6 -2
  22. package/dest/metrics.d.ts.map +1 -1
  23. package/dest/metrics.js +15 -1
  24. package/dest/proposal_handler.d.ts +166 -0
  25. package/dest/proposal_handler.d.ts.map +1 -0
  26. package/dest/proposal_handler.js +1303 -0
  27. package/dest/validator.d.ts +37 -23
  28. package/dest/validator.d.ts.map +1 -1
  29. package/dest/validator.js +268 -270
  30. package/package.json +19 -19
  31. package/src/checkpoint_builder.ts +150 -43
  32. package/src/config.ts +49 -9
  33. package/src/duties/validation_service.ts +52 -54
  34. package/src/factory.ts +29 -5
  35. package/src/index.ts +1 -1
  36. package/src/key_store/ha_key_store.ts +1 -1
  37. package/src/key_store/web3signer_key_store.ts +43 -59
  38. package/src/metrics.ts +21 -1
  39. package/src/proposal_handler.ts +1409 -0
  40. package/src/validator.ts +352 -317
  41. package/dest/block_proposal_handler.d.ts +0 -63
  42. package/dest/block_proposal_handler.d.ts.map +0 -1
  43. package/dest/block_proposal_handler.js +0 -532
  44. package/src/block_proposal_handler.ts +0 -535
package/src/validator.ts CHANGED
@@ -1,74 +1,82 @@
1
1
  import type { BlobClientInterface } from '@aztec/blob-client/client';
2
2
  import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib';
3
3
  import type { EpochCache } from '@aztec/epoch-cache';
4
- import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
5
- import {
6
- BlockNumber,
7
- CheckpointNumber,
8
- EpochNumber,
9
- IndexWithinCheckpoint,
10
- SlotNumber,
11
- } from '@aztec/foundation/branded-types';
4
+ import { CheckpointNumber, EpochNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types';
12
5
  import { Fr } from '@aztec/foundation/curves/bn254';
13
- import { TimeoutError } from '@aztec/foundation/error';
14
6
  import type { EthAddress } from '@aztec/foundation/eth-address';
15
- import type { Signature } from '@aztec/foundation/eth-signature';
7
+ import { Signature } from '@aztec/foundation/eth-signature';
8
+ import { FifoSet } from '@aztec/foundation/fifo-set';
16
9
  import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
17
- import { retryUntil } from '@aztec/foundation/retry';
18
10
  import { RunningPromise } from '@aztec/foundation/running-promise';
19
11
  import { sleep } from '@aztec/foundation/sleep';
20
12
  import { DateProvider } from '@aztec/foundation/timer';
21
13
  import type { KeystoreManager } from '@aztec/node-keystore';
22
- import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
14
+ import type { DuplicateAttestationInfo, DuplicateProposalInfo, OversizedProposalInfo, P2P, PeerId } from '@aztec/p2p';
23
15
  import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
24
- import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
16
+ import {
17
+ OffenseType,
18
+ WANT_TO_CLEAR_SLASH_EVENT,
19
+ WANT_TO_SLASH_EVENT,
20
+ type Watcher,
21
+ type WatcherEmitter,
22
+ getOffenseTypeName,
23
+ } from '@aztec/slasher';
25
24
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
26
- import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
27
- import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
25
+ import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
26
+ import type { CheckpointReexecutionTracker } from '@aztec/stdlib/checkpoint';
27
+ import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
28
28
  import type {
29
- CreateCheckpointProposalLastBlockData,
30
29
  ITxProvider,
31
30
  Validator,
32
31
  ValidatorClientFullConfig,
33
32
  WorldStateSynchronizer,
34
33
  } from '@aztec/stdlib/interfaces/server';
35
- import { type L1ToL2MessageSource, accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging';
34
+ import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
36
35
  import {
37
36
  type BlockProposal,
38
37
  type BlockProposalOptions,
39
- type CheckpointAttestation,
38
+ CheckpointAttestation,
40
39
  CheckpointProposal,
41
40
  type CheckpointProposalCore,
42
41
  type CheckpointProposalOptions,
42
+ type CoordinationSignatureContext,
43
43
  } from '@aztec/stdlib/p2p';
44
44
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
45
- import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
45
+ import { ConsensusTimetable } from '@aztec/stdlib/timetable';
46
+ import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
46
47
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
47
48
  import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
48
- import { createHASigner } from '@aztec/validator-ha-signer/factory';
49
- import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
49
+ import {
50
+ createHASigner,
51
+ createLocalSignerWithProtection,
52
+ createSignerFromSharedDb,
53
+ } from '@aztec/validator-ha-signer/factory';
54
+ import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
50
55
  import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
51
56
 
52
57
  import { EventEmitter } from 'events';
53
58
  import type { TypedDataDefinition } from 'viem';
54
59
 
55
- import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
56
60
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
61
+ import { DEFAULT_MAX_GOSSIP_CLOCK_DISPARITY_MS } from './config.js';
57
62
  import { ValidationService } from './duties/validation_service.js';
58
63
  import { HAKeyStore } from './key_store/ha_key_store.js';
59
64
  import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
60
65
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
61
66
  import { ValidatorMetrics } from './metrics.js';
67
+ import {
68
+ type BlockProposalValidationFailureReason,
69
+ type CheckpointProposalValidationFailureResult,
70
+ ProposalHandler,
71
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT,
72
+ SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT,
73
+ } from './proposal_handler.js';
62
74
 
63
75
  // We maintain a set of proposers who have proposed invalid blocks.
64
76
  // Just cap the set to avoid unbounded growth.
65
77
  const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
66
-
67
- // What errors from the block proposal handler result in slashing
68
- const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
69
- 'state_mismatch',
70
- 'failed_txs',
71
- ];
78
+ const MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS = 1000;
79
+ const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
72
80
 
73
81
  /**
74
82
  * Validator Client
@@ -92,7 +100,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
92
100
  /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
93
101
  private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
94
102
 
95
- private proposersOfInvalidBlocks: Set<string> = new Set();
103
+ private proposersOfInvalidBlocks = FifoSet.withLimit<string>(MAX_PROPOSERS_OF_INVALID_BLOCKS);
104
+ private invalidCheckpointProposalOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS);
105
+ private oversizedProposalOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS);
106
+ private badAttestationOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_BAD_ATTESTATIONS);
96
107
 
97
108
  /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
98
109
  private lastAttestedProposal?: CheckpointProposalCore;
@@ -101,14 +112,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
101
112
  private keyStore: ExtendedValidatorKeyStore,
102
113
  private epochCache: EpochCache,
103
114
  private p2pClient: P2P,
104
- private blockProposalHandler: BlockProposalHandler,
115
+ private proposalHandler: ProposalHandler,
105
116
  private blockSource: L2BlockSource,
106
117
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
107
118
  private worldState: WorldStateSynchronizer,
108
119
  private l1ToL2MessageSource: L1ToL2MessageSource,
109
120
  private config: ValidatorClientFullConfig,
110
121
  private blobClient: BlobClientInterface,
111
- private haSigner: ValidatorHASigner | undefined,
122
+ private slashingProtectionSigner: ValidatorHASigner,
112
123
  private dateProvider: DateProvider = new DateProvider(),
113
124
  telemetry: TelemetryClient = getTelemetryClient(),
114
125
  log = createLogger('validator'),
@@ -121,11 +132,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
121
132
  this.tracer = telemetry.getTracer('Validator');
122
133
  this.metrics = new ValidatorMetrics(telemetry);
123
134
 
124
- this.validationService = new ValidationService(keyStore, this.log.createChild('validation-service'));
135
+ this.validationService = new ValidationService(
136
+ keyStore,
137
+ this.getSignatureContext(),
138
+ this.log.createChild('validation-service'),
139
+ );
140
+ this.proposalHandler.setCheckpointProposalValidationFailureCallback((proposal, result, proposalInfo) =>
141
+ this.handleInvalidCheckpointProposal(proposal, result, proposalInfo),
142
+ );
125
143
 
126
144
  // Refresh epoch cache every second to trigger alert if participation in committee changes
127
145
  this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
128
-
129
146
  const myAddresses = this.getValidatorAddresses();
130
147
  this.log.verbose(`Initialized validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
131
148
  }
@@ -194,15 +211,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
194
211
  txProvider: ITxProvider,
195
212
  keyStoreManager: KeystoreManager,
196
213
  blobClient: BlobClientInterface,
214
+ reexecutionTracker: CheckpointReexecutionTracker,
197
215
  dateProvider: DateProvider = new DateProvider(),
198
216
  telemetry: TelemetryClient = getTelemetryClient(),
217
+ slashingProtectionDb?: SlashingProtectionDatabase,
199
218
  ) {
200
219
  const metrics = new ValidatorMetrics(telemetry);
201
- const blockProposalValidator = new BlockProposalValidator(epochCache, {
220
+ const consensusTimetable = new ConsensusTimetable({
221
+ l1Constants: epochCache.getL1Constants(),
222
+ blockDuration: config.blockDurationMs / 1000,
223
+ });
224
+ const blockProposalValidator = new BlockProposalValidator(epochCache, consensusTimetable, {
202
225
  txsPermitted: !config.disableTransactions,
203
- maxTxsPerBlock: config.maxTxsPerBlock,
226
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
227
+ maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint,
228
+ skipSlotValidation: config.skipProposalSlotValidation,
229
+ signatureContext: {
230
+ chainId: config.l1ChainId,
231
+ rollupAddress: config.rollupAddress,
232
+ },
233
+ clockDisparityMs: config.maxGossipClockDisparityMs ?? DEFAULT_MAX_GOSSIP_CLOCK_DISPARITY_MS,
204
234
  });
205
- const blockProposalHandler = new BlockProposalHandler(
235
+ const proposalHandler = new ProposalHandler(
206
236
  checkpointsBuilder,
207
237
  worldState,
208
238
  blockSource,
@@ -210,38 +240,57 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
210
240
  txProvider,
211
241
  blockProposalValidator,
212
242
  epochCache,
243
+ consensusTimetable,
213
244
  config,
245
+ blobClient,
246
+ reexecutionTracker,
214
247
  metrics,
215
248
  dateProvider,
216
249
  telemetry,
250
+ undefined,
217
251
  );
218
252
 
219
253
  const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
220
- let validatorKeyStore: ExtendedValidatorKeyStore = nodeKeystoreAdapter;
221
- let haSigner: ValidatorHASigner | undefined;
222
- if (config.haSigningEnabled) {
254
+ let slashingProtectionSigner: ValidatorHASigner;
255
+ if (slashingProtectionDb) {
256
+ // Shared database mode: use a pre-existing database (e.g. for testing HA setups).
257
+ ({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
258
+ telemetryClient: telemetry,
259
+ dateProvider,
260
+ }));
261
+ } else if (config.haSigningEnabled) {
262
+ // Multi-node HA mode: use PostgreSQL-backed distributed locking.
223
263
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
224
264
  const haConfig = {
225
265
  ...config,
226
266
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
227
267
  };
228
- const { signer } = await createHASigner(haConfig);
229
- haSigner = signer;
230
- validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
268
+ ({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
269
+ telemetryClient: telemetry,
270
+ dateProvider,
271
+ }));
272
+ } else {
273
+ // Single-node mode: use LMDB-backed local signing protection.
274
+ // This prevents double-signing if the node crashes and restarts mid-proposal.
275
+ ({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
276
+ telemetryClient: telemetry,
277
+ dateProvider,
278
+ }));
231
279
  }
280
+ const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
232
281
 
233
282
  const validator = new ValidatorClient(
234
283
  validatorKeyStore,
235
284
  epochCache,
236
285
  p2pClient,
237
- blockProposalHandler,
286
+ proposalHandler,
238
287
  blockSource,
239
288
  checkpointsBuilder,
240
289
  worldState,
241
290
  l1ToL2MessageSource,
242
291
  config,
243
292
  blobClient,
244
- haSigner,
293
+ slashingProtectionSigner,
245
294
  dateProvider,
246
295
  telemetry,
247
296
  );
@@ -255,14 +304,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
255
304
  .filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
256
305
  }
257
306
 
258
- public getBlockProposalHandler() {
259
- return this.blockProposalHandler;
307
+ public getProposalHandler() {
308
+ return this.proposalHandler;
260
309
  }
261
310
 
262
311
  public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
263
312
  return this.keyStore.signTypedDataWithAddress(addr, msg, context);
264
313
  }
265
314
 
315
+ private getSignatureContext(): CoordinationSignatureContext {
316
+ return {
317
+ chainId: this.config.l1ChainId,
318
+ rollupAddress: this.config.rollupAddress,
319
+ };
320
+ }
321
+
266
322
  public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
267
323
  return this.keyStore.getCoinbaseAddress(attestor);
268
324
  }
@@ -275,30 +331,27 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
275
331
  return this.config;
276
332
  }
277
333
 
334
+ public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
335
+ return this.proposalHandler.hasProposalEquivocation(slotNumber);
336
+ }
337
+
338
+ public hasInvalidProposals(slotNumber: SlotNumber): boolean {
339
+ return this.proposalHandler.hasInvalidProposals(slotNumber);
340
+ }
341
+
278
342
  public updateConfig(config: Partial<ValidatorClientFullConfig>) {
279
343
  this.config = { ...this.config, ...config };
344
+ this.proposalHandler.updateConfig(config);
280
345
  }
281
346
 
282
347
  public reloadKeystore(newManager: KeystoreManager): void {
283
- if (this.config.haSigningEnabled && !this.haSigner) {
284
- this.log.warn(
285
- 'HA signing is enabled in config but was not initialized at startup. ' +
286
- 'Restart the node to enable HA signing.',
287
- );
288
- } else if (!this.config.haSigningEnabled && this.haSigner) {
289
- this.log.warn(
290
- 'HA signing was disabled via config update but the HA signer is still active. ' +
291
- 'Restart the node to fully disable HA signing.',
292
- );
293
- }
294
-
295
348
  const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
296
- if (this.haSigner) {
297
- this.keyStore = new HAKeyStore(newAdapter, this.haSigner);
298
- } else {
299
- this.keyStore = newAdapter;
300
- }
301
- this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
349
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
350
+ this.validationService = new ValidationService(
351
+ this.keyStore,
352
+ this.getSignatureContext(),
353
+ this.log.createChild('validation-service'),
354
+ );
302
355
  }
303
356
 
304
357
  public async start() {
@@ -345,18 +398,27 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
345
398
  checkpoint: CheckpointProposalCore,
346
399
  proposalSender: PeerId,
347
400
  ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
348
- this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
401
+ this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
349
402
 
350
403
  // Duplicate proposal handler - triggers slashing for equivocation
351
404
  this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
352
405
  this.handleDuplicateProposal(info);
353
406
  });
354
407
 
408
+ // Oversized proposal handler - triggers slashing for proposals beyond the per-checkpoint block limit
409
+ this.p2pClient.registerOversizedProposalCallback((info: OversizedProposalInfo) => {
410
+ this.handleOversizedProposal(info);
411
+ });
412
+
355
413
  // Duplicate attestation handler - triggers slashing for attestation equivocation
356
414
  this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
357
415
  this.handleDuplicateAttestation(info);
358
416
  });
359
417
 
418
+ this.p2pClient.registerCheckpointAttestationCallback((attestation: CheckpointAttestation) => {
419
+ this.handleCheckpointAttestation(attestation);
420
+ });
421
+
360
422
  const myAddresses = this.getValidatorAddresses();
361
423
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
362
424
 
@@ -384,13 +446,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
384
446
  return false;
385
447
  }
386
448
 
387
- // Ignore proposals from ourselves (may happen in HA setups)
449
+ // Log self-proposals from HA peers (same validator key on different nodes)
388
450
  if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
389
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
451
+ this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
390
452
  proposer: proposer.toString(),
391
453
  slotNumber,
392
454
  });
393
- return false;
394
455
  }
395
456
 
396
457
  // Check if we're in the committee (for metrics purposes)
@@ -404,27 +465,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
404
465
  fishermanMode: this.config.fishermanMode || false,
405
466
  });
406
467
 
407
- // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
408
- // In fisherman mode, we always reexecute to validate proposals.
409
- const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
410
- this.config;
411
- const shouldReexecute =
412
- fishermanMode ||
413
- (slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
414
- (partOfCommittee && validatorReexecute) ||
415
- alwaysReexecuteBlockProposals ||
416
- this.blobClient.canUpload();
417
-
418
- const validationResult = await this.blockProposalHandler.handleBlockProposal(
419
- proposal,
420
- proposalSender,
421
- !!shouldReexecute && !escapeHatchOpen,
422
- );
468
+ // Reexecute outside the escape hatch so slashing observers can detect invalid proposals even when penalties are 0.
469
+ const validationResult = await this.proposalHandler.handleBlockProposal(proposal, proposalSender, !escapeHatchOpen);
423
470
 
424
471
  if (!validationResult.isValid) {
425
- this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
426
-
427
472
  const reason = validationResult.reason || 'unknown';
473
+
474
+ this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
475
+
428
476
  // Classify failure reason: bad proposal vs node issue
429
477
  const badProposalReasons: BlockProposalValidationFailureReason[] = [
430
478
  'invalid_proposal',
@@ -432,6 +480,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
432
480
  'failed_txs',
433
481
  'in_hash_mismatch',
434
482
  'parent_block_wrong_slot',
483
+ 'duplicate_txs',
484
+ 'invalid_embedded_txs',
435
485
  ];
436
486
 
437
487
  if (badProposalReasons.includes(reason as BlockProposalValidationFailureReason)) {
@@ -441,15 +491,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
441
491
  this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
442
492
  }
443
493
 
444
- // Slash invalid block proposals (can happen even when not in committee)
445
494
  if (
446
495
  !escapeHatchOpen &&
447
496
  validationResult.reason &&
448
- SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason) &&
449
- slashBroadcastedInvalidBlockPenalty > 0n
497
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)
450
498
  ) {
451
- this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo);
499
+ this.log.info(`Detected invalid block proposal offense`, {
500
+ ...proposalInfo,
501
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
502
+ offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL),
503
+ });
452
504
  this.slashInvalidBlock(proposal);
505
+ this.markInvalidProposalSlot(proposal.slotNumber);
453
506
  }
454
507
  return false;
455
508
  }
@@ -479,68 +532,56 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
479
532
  proposal: CheckpointProposalCore,
480
533
  _proposalSender: PeerId,
481
534
  ): Promise<CheckpointAttestation[] | undefined> {
482
- const slotNumber = proposal.slotNumber;
535
+ const proposalSlotNumber = proposal.slotNumber;
483
536
  const proposer = proposal.getSender();
484
537
 
485
538
  // If escape hatch is open for this slot's epoch, do not attest.
486
- if (await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber)) {
487
- this.log.warn(`Escape hatch open for slot ${slotNumber}, skipping checkpoint attestation handling`);
539
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
540
+ this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
488
541
  return undefined;
489
542
  }
490
543
 
491
- // Reject proposals with invalid signatures
492
- if (!proposer) {
493
- this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
544
+ // Early-out for equivocation: refuses if we've already attested to a higher slot.
545
+ if (!this.shouldAttestToSlot(proposalSlotNumber)) {
494
546
  return undefined;
495
547
  }
496
548
 
497
549
  // Ignore proposals from ourselves (may happen in HA setups)
498
- if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
499
- this.log.warn(`Ignoring block proposal from self for slot ${slotNumber}`, {
550
+ if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
551
+ this.log.debug(`Not attesting to block proposal from self for slot ${proposalSlotNumber}`, {
500
552
  proposer: proposer.toString(),
501
- slotNumber,
553
+ proposalSlotNumber,
502
554
  });
503
555
  return undefined;
504
556
  }
505
557
 
506
- // Validate fee asset price modifier is within allowed range
507
- if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
508
- this.log.warn(
509
- `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`,
510
- );
511
- return undefined;
512
- }
513
-
514
- // Check that I have any address in current committee before attesting
515
- const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
558
+ // Check that I have any address in the committee where this checkpoint will land before attesting
559
+ const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
516
560
  const partOfCommittee = inCommittee.length > 0;
517
561
 
518
562
  const proposalInfo = {
519
- slotNumber,
563
+ proposalSlotNumber,
520
564
  archive: proposal.archive.toString(),
521
- proposer: proposer.toString(),
522
- txCount: proposal.txHashes.length,
565
+ proposer: proposer?.toString(),
523
566
  };
524
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
567
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
525
568
  ...proposalInfo,
526
- txHashes: proposal.txHashes.map(t => t.toString()),
527
569
  fishermanMode: this.config.fishermanMode || false,
528
570
  });
529
571
 
530
- // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
572
+ // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
573
+ // Uses the cached result from the all-nodes callback if available (avoids double validation).
574
+ let checkpointNumber: CheckpointNumber;
531
575
  if (this.config.skipCheckpointProposalValidation) {
532
- this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
576
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
577
+ checkpointNumber = CheckpointNumber(0);
533
578
  } else {
534
- const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
579
+ const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
535
580
  if (!validationResult.isValid) {
536
581
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
537
582
  return undefined;
538
583
  }
539
- }
540
-
541
- // Upload blobs to filestore if we can (fire and forget)
542
- if (this.blobClient.canUpload()) {
543
- void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
584
+ checkpointNumber = validationResult.checkpointNumber;
544
585
  }
545
586
 
546
587
  // Check that I have any address in current committee before attesting
@@ -551,16 +592,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
551
592
  }
552
593
 
553
594
  // Provided all of the above checks pass, we can attest to the proposal
554
- this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, {
555
- ...proposalInfo,
556
- inCommittee: partOfCommittee,
557
- fishermanMode: this.config.fishermanMode || false,
558
- });
595
+ this.log.info(
596
+ `${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
597
+ {
598
+ ...proposalInfo,
599
+ inCommittee: partOfCommittee,
600
+ fishermanMode: this.config.fishermanMode || false,
601
+ },
602
+ );
559
603
 
560
604
  this.metrics.incSuccessfulAttestations(inCommittee.length);
561
605
 
562
606
  // Track epoch participation per attester: count each (attester, epoch) pair at most once
563
- const proposalEpoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants());
607
+ const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
564
608
  for (const attester of inCommittee) {
565
609
  const key = attester.toString();
566
610
  const lastEpoch = this.lastAttestedEpochByAttester.get(key);
@@ -588,14 +632,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
588
632
 
589
633
  if (this.config.fishermanMode) {
590
634
  // bail out early and don't save attestations to the pool in fisherman mode
591
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
635
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
592
636
  ...proposalInfo,
593
637
  attestors: attestors.map(a => a.toString()),
594
638
  });
595
639
  return undefined;
596
640
  }
597
641
 
598
- return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
642
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
599
643
  }
600
644
 
601
645
  /**
@@ -622,13 +666,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
622
666
  private async createCheckpointAttestationsFromProposal(
623
667
  proposal: CheckpointProposalCore,
624
668
  attestors: EthAddress[] = [],
669
+ checkpointNumber: CheckpointNumber,
625
670
  ): Promise<CheckpointAttestation[] | undefined> {
626
671
  // Equivocation check: must happen right before signing to minimize the race window
627
672
  if (!this.shouldAttestToSlot(proposal.slotNumber)) {
628
673
  return undefined;
629
674
  }
630
675
 
631
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
676
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
632
677
 
633
678
  // Track the proposal we attested to (to prevent equivocation)
634
679
  this.lastAttestedProposal = proposal;
@@ -637,164 +682,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
637
682
  return attestations;
638
683
  }
639
684
 
640
- /**
641
- * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
642
- * @returns Validation result with isValid flag and reason if invalid.
643
- */
644
- private async validateCheckpointProposal(
645
- proposal: CheckpointProposalCore,
646
- proposalInfo: LogData,
647
- ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
648
- const slot = proposal.slotNumber;
649
-
650
- // Timeout block syncing at the start of the next slot
651
- const config = this.checkpointsBuilder.getConfig();
652
- const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
653
- const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
654
-
655
- // Wait for last block to sync by archive
656
- let lastBlockHeader: BlockHeader | undefined;
657
- try {
658
- lastBlockHeader = await retryUntil(
659
- async () => {
660
- await this.blockSource.syncImmediate();
661
- return this.blockSource.getBlockHeaderByArchive(proposal.archive);
662
- },
663
- `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
664
- timeoutSeconds,
665
- 0.5,
666
- );
667
- } catch (err) {
668
- if (err instanceof TimeoutError) {
669
- this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
670
- return { isValid: false, reason: 'last_block_not_found' };
671
- }
672
- this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
673
- return { isValid: false, reason: 'block_fetch_error' };
674
- }
675
-
676
- if (!lastBlockHeader) {
677
- this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
678
- return { isValid: false, reason: 'last_block_not_found' };
679
- }
680
-
681
- // Get all full blocks for the slot and checkpoint
682
- const blocks = await this.blockSource.getBlocksForSlot(slot);
683
- if (blocks.length === 0) {
684
- this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
685
- return { isValid: false, reason: 'no_blocks_for_slot' };
686
- }
687
-
688
- // Ensure the last block for this slot matches the archive in the checkpoint proposal
689
- if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
690
- this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
691
- return { isValid: false, reason: 'last_block_archive_mismatch' };
692
- }
693
-
694
- this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
695
- ...proposalInfo,
696
- blockNumbers: blocks.map(b => b.number),
697
- });
698
-
699
- // Get checkpoint constants from first block
700
- const firstBlock = blocks[0];
701
- const constants = this.extractCheckpointConstants(firstBlock);
702
- const checkpointNumber = firstBlock.checkpointNumber;
703
-
704
- // Get L1-to-L2 messages for this checkpoint
705
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
706
-
707
- // Collect the out hashes of all the checkpoints before this one in the same epoch
708
- const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
709
- const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
710
- .filter(c => c.checkpointNumber < checkpointNumber)
711
- .map(c => c.checkpointOutHash);
712
-
713
- // Fork world state at the block before the first block
714
- const parentBlockNumber = BlockNumber(firstBlock.number - 1);
715
- const fork = await this.worldState.fork(parentBlockNumber);
716
-
717
- try {
718
- // Create checkpoint builder with all existing blocks
719
- const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
720
- checkpointNumber,
721
- constants,
722
- proposal.feeAssetPriceModifier,
723
- l1ToL2Messages,
724
- previousCheckpointOutHashes,
725
- fork,
726
- blocks,
727
- this.log.getBindings(),
728
- );
729
-
730
- // Complete the checkpoint to get computed values
731
- const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
732
-
733
- // Compare checkpoint header with proposal
734
- if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
735
- this.log.warn(`Checkpoint header mismatch`, {
736
- ...proposalInfo,
737
- computed: computedCheckpoint.header.toInspect(),
738
- proposal: proposal.checkpointHeader.toInspect(),
739
- });
740
- return { isValid: false, reason: 'checkpoint_header_mismatch' };
741
- }
742
-
743
- // Compare archive root with proposal
744
- if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
745
- this.log.warn(`Archive root mismatch`, {
746
- ...proposalInfo,
747
- computed: computedCheckpoint.archive.root.toString(),
748
- proposal: proposal.archive.toString(),
749
- });
750
- return { isValid: false, reason: 'archive_mismatch' };
751
- }
752
-
753
- // Check that the accumulated epoch out hash matches the value in the proposal.
754
- // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
755
- const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
756
- const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
757
- const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
758
- if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
759
- this.log.warn(`Epoch out hash mismatch`, {
760
- proposalEpochOutHash: proposalEpochOutHash.toString(),
761
- computedEpochOutHash: computedEpochOutHash.toString(),
762
- checkpointOutHash: checkpointOutHash.toString(),
763
- previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
764
- ...proposalInfo,
765
- });
766
- return { isValid: false, reason: 'out_hash_mismatch' };
767
- }
768
-
769
- this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
770
- return { isValid: true };
771
- } finally {
772
- await fork.close();
773
- }
774
- }
775
-
776
- /**
777
- * Extract checkpoint global variables from a block.
778
- */
779
- private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
780
- const gv = block.header.globalVariables;
781
- return {
782
- chainId: gv.chainId,
783
- version: gv.version,
784
- slotNumber: gv.slotNumber,
785
- timestamp: gv.timestamp,
786
- coinbase: gv.coinbase,
787
- feeRecipient: gv.feeRecipient,
788
- gasFees: gv.gasFees,
789
- };
790
- }
791
-
792
685
  /**
793
686
  * Uploads blobs for a checkpoint to the filestore (fire and forget).
794
687
  */
795
688
  protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
796
689
  try {
797
- const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
690
+ const lastBlockHeader = (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header;
798
691
  if (!lastBlockHeader) {
799
692
  this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
800
693
  return;
@@ -827,12 +720,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
827
720
  return;
828
721
  }
829
722
 
830
- // Trim the set if it's too big.
831
- if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
832
- // remove oldest proposer. `values` is guaranteed to be in insertion order.
833
- this.proposersOfInvalidBlocks.delete(this.proposersOfInvalidBlocks.values().next().value!);
834
- }
835
-
836
723
  this.proposersOfInvalidBlocks.add(proposer.toString());
837
724
 
838
725
  this.emit(WANT_TO_SLASH_EVENT, [
@@ -845,20 +732,148 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
845
732
  ]);
846
733
  }
847
734
 
735
+ private handleInvalidCheckpointProposal(
736
+ proposal: CheckpointProposalCore,
737
+ result: CheckpointProposalValidationFailureResult,
738
+ proposalInfo: LogData,
739
+ ): void {
740
+ if (!SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
741
+ return;
742
+ }
743
+
744
+ // The slot is already marked invalid by the all-nodes checkpoint handler that invokes this callback,
745
+ // so we only emit the proposer slash event here.
746
+ if (this.slashInvalidCheckpointProposal(proposal)) {
747
+ this.log.info(`Detected invalid checkpoint proposal offense`, {
748
+ ...proposalInfo,
749
+ reason: result.reason,
750
+ amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
751
+ offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL),
752
+ });
753
+ }
754
+ }
755
+
756
+ private slashInvalidCheckpointProposal(proposal: CheckpointProposalCore): boolean {
757
+ const proposer = proposal.getSender();
758
+ if (!proposer) {
759
+ this.log.warn(`Cannot slash checkpoint proposal with invalid signature`, {
760
+ slotNumber: proposal.slotNumber,
761
+ archive: proposal.archive.toString(),
762
+ });
763
+ return false;
764
+ }
765
+
766
+ const offenseType = OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL;
767
+ const offenseKey = `${proposer.toString()}:${offenseType}:${proposal.slotNumber}`;
768
+ if (!this.invalidCheckpointProposalOffenseKeys.addIfAbsent(offenseKey)) {
769
+ return false;
770
+ }
771
+
772
+ this.emit(WANT_TO_SLASH_EVENT, [
773
+ {
774
+ validator: proposer,
775
+ amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
776
+ offenseType,
777
+ epochOrSlot: BigInt(proposal.slotNumber),
778
+ },
779
+ ]);
780
+ return true;
781
+ }
782
+
783
+ private markInvalidProposalSlot(slotNumber: SlotNumber): void {
784
+ this.proposalHandler.markInvalidProposalSlot(slotNumber);
785
+ }
786
+
787
+ private handleCheckpointAttestation(attestation: CheckpointAttestation): void {
788
+ const slotNumber = attestation.slotNumber;
789
+ if (
790
+ !this.proposalHandler.hasInvalidProposals(slotNumber) ||
791
+ this.proposalHandler.hasProposalEquivocation(slotNumber)
792
+ ) {
793
+ return;
794
+ }
795
+
796
+ const attester = attestation.getSender();
797
+ if (!attester) {
798
+ this.log.warn(`Cannot slash checkpoint attestation with invalid signature`, {
799
+ slotNumber,
800
+ archive: attestation.archive.toString(),
801
+ });
802
+ return;
803
+ }
804
+
805
+ this.slashAttestedToInvalidCheckpointProposal(slotNumber, attester);
806
+ }
807
+
808
+ private slashAttestedToInvalidCheckpointProposal(slotNumber: SlotNumber, attester: EthAddress): void {
809
+ const offenseKey = `${attester.toString()}:${OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL}:${slotNumber}`;
810
+ if (!this.badAttestationOffenseKeys.addIfAbsent(offenseKey)) {
811
+ return;
812
+ }
813
+
814
+ this.log.info(`Detected attestation to invalid checkpoint proposal offense`, {
815
+ attester: attester.toString(),
816
+ slotNumber,
817
+ amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
818
+ offenseType: getOffenseTypeName(OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL),
819
+ });
820
+
821
+ this.emit(WANT_TO_SLASH_EVENT, [
822
+ {
823
+ validator: attester,
824
+ amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
825
+ offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
826
+ epochOrSlot: BigInt(slotNumber),
827
+ },
828
+ ]);
829
+ }
830
+
831
+ /**
832
+ * Handle detection of an oversized block proposal: one whose index within its checkpoint lands at or
833
+ * beyond the consensus per-checkpoint block limit. A single signed proposal at an illegal index is
834
+ * self-contained evidence, so emit an invalid-block-proposal slash event for the proposer, deduped per
835
+ * (proposer, slot) since the p2p layer reports every oversized proposal it stores.
836
+ */
837
+ private handleOversizedProposal(info: OversizedProposalInfo): void {
838
+ const { slot, proposer } = info;
839
+ const offenseType = OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL;
840
+ if (!this.oversizedProposalOffenseKeys.addIfAbsent(`${proposer.toString()}:${offenseType}:${slot}`)) {
841
+ return;
842
+ }
843
+
844
+ this.log.info(`Detected oversized block proposal offense from ${proposer.toString()} at slot ${slot}`, {
845
+ proposer: proposer.toString(),
846
+ slot,
847
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
848
+ offenseType: getOffenseTypeName(offenseType),
849
+ });
850
+
851
+ this.emit(WANT_TO_SLASH_EVENT, [
852
+ {
853
+ validator: proposer,
854
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
855
+ offenseType,
856
+ epochOrSlot: BigInt(slot),
857
+ },
858
+ ]);
859
+ }
860
+
848
861
  /**
849
862
  * Handle detection of a duplicate proposal (equivocation).
850
863
  * Emits a slash event when a proposer sends multiple proposals for the same position.
851
864
  */
852
865
  private handleDuplicateProposal(info: DuplicateProposalInfo): void {
853
866
  const { slot, proposer, type } = info;
867
+ this.proposalHandler.markProposalEquivocation(slot);
854
868
 
855
- this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
869
+ this.log.info(`Detected duplicate ${type} proposal offense from ${proposer.toString()} at slot ${slot}`, {
856
870
  proposer: proposer.toString(),
857
871
  slot,
858
872
  type,
873
+ amount: this.config.slashDuplicateProposalPenalty,
874
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_PROPOSAL),
859
875
  });
860
876
 
861
- // Emit slash event
862
877
  this.emit(WANT_TO_SLASH_EVENT, [
863
878
  {
864
879
  validator: proposer,
@@ -867,6 +882,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
867
882
  epochOrSlot: BigInt(slot),
868
883
  },
869
884
  ]);
885
+
886
+ this.emit(WANT_TO_CLEAR_SLASH_EVENT, [
887
+ {
888
+ offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
889
+ epochOrSlot: BigInt(slot),
890
+ },
891
+ ]);
870
892
  }
871
893
 
872
894
  /**
@@ -876,9 +898,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
876
898
  private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
877
899
  const { slot, attester } = info;
878
900
 
879
- this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
901
+ this.log.info(`Detected duplicate attestation offense from ${attester.toString()} at slot ${slot}`, {
880
902
  attester: attester.toString(),
881
903
  slot,
904
+ amount: this.config.slashDuplicateAttestationPenalty,
905
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_ATTESTATION),
882
906
  });
883
907
 
884
908
  this.emit(WANT_TO_SLASH_EVENT, [
@@ -893,6 +917,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
893
917
 
894
918
  async createBlockProposal(
895
919
  blockHeader: BlockHeader,
920
+ checkpointNumber: CheckpointNumber,
896
921
  indexWithinCheckpoint: IndexWithinCheckpoint,
897
922
  inHash: Fr,
898
923
  archive: Fr,
@@ -919,6 +944,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
919
944
  );
920
945
  const newProposal = await this.validationService.createBlockProposal(
921
946
  blockHeader,
947
+ checkpointNumber,
922
948
  indexWithinCheckpoint,
923
949
  inHash,
924
950
  archive,
@@ -926,7 +952,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
926
952
  proposerAddress,
927
953
  {
928
954
  ...options,
929
- broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
955
+ broadcastInvalidBlockProposal:
956
+ options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal,
930
957
  },
931
958
  );
932
959
  this.lastProposedBlock = newProposal;
@@ -936,8 +963,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
936
963
  async createCheckpointProposal(
937
964
  checkpointHeader: CheckpointHeader,
938
965
  archive: Fr,
966
+ checkpointNumber: CheckpointNumber,
939
967
  feeAssetPriceModifier: bigint,
940
- lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
968
+ lastBlockProposal: BlockProposal | undefined,
941
969
  proposerAddress: EthAddress | undefined,
942
970
  options: CheckpointProposalOptions = {},
943
971
  ): Promise<CheckpointProposal> {
@@ -958,12 +986,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
958
986
  const newProposal = await this.validationService.createCheckpointProposal(
959
987
  checkpointHeader,
960
988
  archive,
989
+ checkpointNumber,
961
990
  feeAssetPriceModifier,
962
- lastBlockInfo,
991
+ lastBlockProposal,
963
992
  proposerAddress,
964
993
  options,
965
994
  );
966
995
  this.lastProposedCheckpoint = newProposal;
996
+ // Self-record this slot's outcome on the re-execution tracker. Proposers don't run their
997
+ // own proposals through `handleCheckpointProposal`, so without this call the proposer's
998
+ // sentinel would see no outcome for slots it proposed and would mis-attribute itself as
999
+ // inactive. We pass the locally-computed `archive` (not `newProposal.archive`, which may
1000
+ // be intentionally corrupted under test-only flags); from the proposer's local-view
1001
+ // perspective the work it just completed is valid by definition.
1002
+ this.proposalHandler.recordOwnCheckpointProposalAsValid(checkpointHeader.slotNumber, archive, checkpointNumber);
967
1003
  return newProposal;
968
1004
  }
969
1005
 
@@ -975,16 +1011,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
975
1011
  attestationsAndSigners: CommitteeAttestationsAndSigners,
976
1012
  proposer: EthAddress,
977
1013
  slot: SlotNumber,
978
- blockNumber: BlockNumber | CheckpointNumber,
1014
+ checkpointNumber: CheckpointNumber,
979
1015
  ): Promise<Signature> {
980
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
1016
+ return await this.validationService.signAttestationsAndSigners(
1017
+ attestationsAndSigners,
1018
+ proposer,
1019
+ slot,
1020
+ checkpointNumber,
1021
+ );
981
1022
  }
982
1023
 
983
- async collectOwnAttestations(proposal: CheckpointProposal): Promise<CheckpointAttestation[]> {
1024
+ async collectOwnAttestations(
1025
+ proposal: CheckpointProposal,
1026
+ checkpointNumber: CheckpointNumber,
1027
+ ): Promise<CheckpointAttestation[]> {
984
1028
  const slot = proposal.slotNumber;
985
1029
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
986
1030
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
987
- const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
1031
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
988
1032
 
989
1033
  if (!attestations) {
990
1034
  return [];
@@ -1003,6 +1047,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
1003
1047
  proposal: CheckpointProposal,
1004
1048
  required: number,
1005
1049
  deadline: Date,
1050
+ checkpointNumber: CheckpointNumber,
1006
1051
  ): Promise<CheckpointAttestation[]> {
1007
1052
  // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
1008
1053
  const slot = proposal.slotNumber;
@@ -1015,33 +1060,23 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
1015
1060
  throw new AttestationTimeoutError(0, required, slot);
1016
1061
  }
1017
1062
 
1018
- await this.collectOwnAttestations(proposal);
1063
+ await this.collectOwnAttestations(proposal, checkpointNumber);
1019
1064
 
1020
- const proposalId = proposal.archive.toString();
1065
+ const proposalPayloadHash = proposal.getPayloadHash();
1021
1066
  const myAddresses = this.getValidatorAddresses();
1022
1067
 
1023
1068
  let attestations: CheckpointAttestation[] = [];
1024
1069
  while (true) {
1025
- // Filter out attestations with a mismatching archive. This should NOT happen since we have verified
1026
- // the proposer signature (ie our own) before accepting the attestation into the pool via the p2p client.
1027
- const collectedAttestations = (await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalId)).filter(
1028
- attestation => {
1029
- if (!attestation.archive.equals(proposal.archive)) {
1030
- this.log.warn(
1031
- `Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`,
1032
- { attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() },
1033
- );
1034
- return false;
1035
- }
1036
- return true;
1037
- },
1038
- );
1070
+ // The pool already filters by proposal payload hash; if any attestation slips through with a
1071
+ // mismatched payload hash, drop it defensively. Equivocations are emitted as separate slash
1072
+ // events from libp2p_service.
1073
+ const collectedAttestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalPayloadHash);
1039
1074
 
1040
1075
  // Log new attestations we collected
1041
1076
  const oldSenders = attestations.map(attestation => attestation.getSender());
1042
1077
  for (const collected of collectedAttestations) {
1043
1078
  const collectedSender = collected.getSender();
1044
- // Skip attestations with invalid signatures
1079
+ // Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
1045
1080
  if (!collectedSender) {
1046
1081
  this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
1047
1082
  continue;