@aztec/validator-client 0.0.1-commit.2448fdb → 0.0.1-commit.2606882

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dest/validator.js CHANGED
@@ -1,13 +1,16 @@
1
+ import { getBlobsPerL1Block } from '@aztec/blob-lib';
2
+ import { CheckpointNumber } from '@aztec/foundation/branded-types';
3
+ import { FifoSet } from '@aztec/foundation/fifo-set';
1
4
  import { createLogger } from '@aztec/foundation/log';
2
5
  import { RunningPromise } from '@aztec/foundation/running-promise';
3
6
  import { sleep } from '@aztec/foundation/sleep';
4
7
  import { DateProvider } from '@aztec/foundation/timer';
5
8
  import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
6
- import { OffenseType, WANT_TO_SLASH_EVENT } from '@aztec/slasher';
9
+ import { OffenseType, WANT_TO_CLEAR_SLASH_EVENT, WANT_TO_SLASH_EVENT, getOffenseTypeName } from '@aztec/slasher';
7
10
  import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
8
11
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
9
12
  import { getTelemetryClient } from '@aztec/telemetry-client';
10
- import { createHASigner, createSignerFromSharedDb } from '@aztec/validator-ha-signer/factory';
13
+ import { createHASigner, createLocalSignerWithProtection, createSignerFromSharedDb } from '@aztec/validator-ha-signer/factory';
11
14
  import { DutyType } from '@aztec/validator-ha-signer/types';
12
15
  import { EventEmitter } from 'events';
13
16
  import { ValidationService } from './duties/validation_service.js';
@@ -18,11 +21,36 @@ import { ProposalHandler } from './proposal_handler.js';
18
21
  // We maintain a set of proposers who have proposed invalid blocks.
19
22
  // Just cap the set to avoid unbounded growth.
20
23
  const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
24
+ const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
25
+ const MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS = 1000;
26
+ const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
21
27
  // What errors from the block proposal handler result in slashing
22
28
  const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
23
29
  'state_mismatch',
24
- 'failed_txs'
30
+ 'failed_txs',
31
+ 'global_variables_mismatch',
32
+ 'invalid_proposal',
33
+ 'parent_block_wrong_slot',
34
+ 'in_hash_mismatch'
25
35
  ];
36
+ const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT = {
37
+ // enabled
38
+ ['invalid_fee_asset_price_modifier']: true,
39
+ ['checkpoint_header_mismatch']: true,
40
+ // These late mismatches should normally be caught by earlier checks, but if reached after validating the local
41
+ // checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
42
+ ['archive_mismatch']: true,
43
+ ['out_hash_mismatch']: true,
44
+ ['no_blocks_for_slot']: true,
45
+ ['too_many_blocks_in_checkpoint']: true,
46
+ ['checkpoint_validation_failed']: true,
47
+ ['last_block_archive_mismatch']: true,
48
+ // disabled
49
+ ['invalid_signature']: false,
50
+ ['last_block_not_found']: false,
51
+ ['block_fetch_error']: false,
52
+ ['checkpoint_already_published']: false
53
+ };
26
54
  /**
27
55
  * Validator Client
28
56
  */ export class ValidatorClient extends EventEmitter {
@@ -30,9 +58,13 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
30
58
  epochCache;
31
59
  p2pClient;
32
60
  proposalHandler;
61
+ blockSource;
62
+ checkpointsBuilder;
63
+ worldState;
64
+ l1ToL2MessageSource;
33
65
  config;
34
66
  blobClient;
35
- haSigner;
67
+ slashingProtectionSigner;
36
68
  dateProvider;
37
69
  tracer;
38
70
  validationService;
@@ -46,14 +78,19 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
46
78
  epochCacheUpdateLoop;
47
79
  /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */ lastAttestedEpochByAttester;
48
80
  proposersOfInvalidBlocks;
81
+ slotsWithInvalidProposals;
82
+ invalidCheckpointProposalOffenseKeys;
83
+ badAttestationOffenseKeys;
84
+ slotsWithProposalEquivocation;
49
85
  /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */ lastAttestedProposal;
50
- constructor(keyStore, epochCache, p2pClient, proposalHandler, config, blobClient, haSigner, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), log = createLogger('validator')){
51
- super(), this.keyStore = keyStore, this.epochCache = epochCache, this.p2pClient = p2pClient, this.proposalHandler = proposalHandler, this.config = config, this.blobClient = blobClient, this.haSigner = haSigner, this.dateProvider = dateProvider, this.hasRegisteredHandlers = false, this.lastAttestedEpochByAttester = new Map(), this.proposersOfInvalidBlocks = new Set();
86
+ constructor(keyStore, epochCache, p2pClient, proposalHandler, blockSource, checkpointsBuilder, worldState, l1ToL2MessageSource, config, blobClient, slashingProtectionSigner, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), log = createLogger('validator')){
87
+ super(), this.keyStore = keyStore, this.epochCache = epochCache, this.p2pClient = p2pClient, this.proposalHandler = proposalHandler, this.blockSource = blockSource, this.checkpointsBuilder = checkpointsBuilder, this.worldState = worldState, this.l1ToL2MessageSource = l1ToL2MessageSource, this.config = config, this.blobClient = blobClient, this.slashingProtectionSigner = slashingProtectionSigner, this.dateProvider = dateProvider, this.hasRegisteredHandlers = false, this.lastAttestedEpochByAttester = new Map(), this.proposersOfInvalidBlocks = FifoSet.withLimit(MAX_PROPOSERS_OF_INVALID_BLOCKS), this.slotsWithInvalidProposals = FifoSet.withLimit(MAX_TRACKED_INVALID_PROPOSAL_SLOTS), this.invalidCheckpointProposalOffenseKeys = FifoSet.withLimit(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS), this.badAttestationOffenseKeys = FifoSet.withLimit(MAX_TRACKED_BAD_ATTESTATIONS), this.slotsWithProposalEquivocation = FifoSet.withLimit(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
52
88
  // Create child logger with fisherman prefix if in fisherman mode
53
89
  this.log = config.fishermanMode ? log.createChild('[FISHERMAN]') : log;
54
90
  this.tracer = telemetry.getTracer('Validator');
55
91
  this.metrics = new ValidatorMetrics(telemetry);
56
- this.validationService = new ValidationService(keyStore, this.log.createChild('validation-service'));
92
+ this.validationService = new ValidationService(keyStore, this.getSignatureContext(), this.log.createChild('validation-service'));
93
+ this.proposalHandler.setCheckpointProposalValidationFailureCallback((proposal, result, proposalInfo)=>this.handleInvalidCheckpointProposal(proposal, result, proposalInfo));
57
94
  // Refresh epoch cache every second to trigger alert if participation in committee changes
58
95
  this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
59
96
  const myAddresses = this.getValidatorAddresses();
@@ -103,32 +140,48 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
103
140
  this.log.error(`Error updating epoch committee`, err);
104
141
  }
105
142
  }
106
- static async new(config, checkpointsBuilder, worldState, epochCache, p2pClient, blockSource, l1ToL2MessageSource, txProvider, keyStoreManager, blobClient, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), slashingProtectionDb) {
143
+ static async new(config, checkpointsBuilder, worldState, epochCache, p2pClient, blockSource, l1ToL2MessageSource, txProvider, keyStoreManager, blobClient, reexecutionTracker, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), slashingProtectionDb) {
107
144
  const metrics = new ValidatorMetrics(telemetry);
108
145
  const blockProposalValidator = new BlockProposalValidator(epochCache, {
109
146
  txsPermitted: !config.disableTransactions,
110
- maxTxsPerBlock: config.validateMaxTxsPerBlock
147
+ maxTxsPerBlock: config.validateMaxTxsPerBlock,
148
+ maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint,
149
+ skipSlotValidation: config.skipProposalSlotValidation,
150
+ signatureContext: {
151
+ chainId: config.l1ChainId,
152
+ rollupAddress: config.rollupAddress
153
+ }
111
154
  });
112
- const proposalHandler = new ProposalHandler(checkpointsBuilder, worldState, blockSource, l1ToL2MessageSource, txProvider, blockProposalValidator, epochCache, config, blobClient, metrics, dateProvider, telemetry);
155
+ const proposalHandler = new ProposalHandler(checkpointsBuilder, worldState, blockSource, l1ToL2MessageSource, txProvider, blockProposalValidator, epochCache, config, blobClient, reexecutionTracker, metrics, dateProvider, telemetry, undefined);
113
156
  const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
114
- let validatorKeyStore = nodeKeystoreAdapter;
115
- let haSigner;
157
+ let slashingProtectionSigner;
116
158
  if (slashingProtectionDb) {
117
159
  // Shared database mode: use a pre-existing database (e.g. for testing HA setups).
118
- const { signer } = createSignerFromSharedDb(slashingProtectionDb, config);
119
- haSigner = signer;
120
- validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
160
+ ({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
161
+ telemetryClient: telemetry,
162
+ dateProvider
163
+ }));
121
164
  } else if (config.haSigningEnabled) {
165
+ // Multi-node HA mode: use PostgreSQL-backed distributed locking.
122
166
  // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
123
167
  const haConfig = {
124
168
  ...config,
125
169
  maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000
126
170
  };
127
- const { signer } = await createHASigner(haConfig);
128
- haSigner = signer;
129
- validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
130
- }
131
- const validator = new ValidatorClient(validatorKeyStore, epochCache, p2pClient, proposalHandler, config, blobClient, haSigner, dateProvider, telemetry);
171
+ ({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
172
+ telemetryClient: telemetry,
173
+ dateProvider
174
+ }));
175
+ } else {
176
+ // Single-node mode: use LMDB-backed local signing protection.
177
+ // This prevents double-signing if the node crashes and restarts mid-proposal.
178
+ ({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
179
+ telemetryClient: telemetry,
180
+ dateProvider
181
+ }));
182
+ }
183
+ const validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
184
+ const validator = new ValidatorClient(validatorKeyStore, epochCache, p2pClient, proposalHandler, blockSource, checkpointsBuilder, worldState, l1ToL2MessageSource, config, blobClient, slashingProtectionSigner, dateProvider, telemetry);
132
185
  return validator;
133
186
  }
134
187
  getValidatorAddresses() {
@@ -140,6 +193,12 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
140
193
  signWithAddress(addr, msg, context) {
141
194
  return this.keyStore.signTypedDataWithAddress(addr, msg, context);
142
195
  }
196
+ getSignatureContext() {
197
+ return {
198
+ chainId: this.config.l1ChainId,
199
+ rollupAddress: this.config.rollupAddress
200
+ };
201
+ }
143
202
  getCoinbaseForAttestor(attestor) {
144
203
  return this.keyStore.getCoinbaseAddress(attestor);
145
204
  }
@@ -149,25 +208,23 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
149
208
  getConfig() {
150
209
  return this.config;
151
210
  }
211
+ hasProposalEquivocation(slotNumber) {
212
+ return this.slotsWithProposalEquivocation.has(slotNumber);
213
+ }
214
+ hasInvalidProposals(slotNumber) {
215
+ return this.slotsWithInvalidProposals.has(slotNumber);
216
+ }
152
217
  updateConfig(config) {
153
218
  this.config = {
154
219
  ...this.config,
155
220
  ...config
156
221
  };
222
+ this.proposalHandler.updateConfig(config);
157
223
  }
158
224
  reloadKeystore(newManager) {
159
- if (this.config.haSigningEnabled && !this.haSigner) {
160
- this.log.warn('HA signing is enabled in config but was not initialized at startup. ' + 'Restart the node to enable HA signing.');
161
- } else if (!this.config.haSigningEnabled && this.haSigner) {
162
- this.log.warn('HA signing was disabled via config update but the HA signer is still active. ' + 'Restart the node to fully disable HA signing.');
163
- }
164
225
  const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
165
- if (this.haSigner) {
166
- this.keyStore = new HAKeyStore(newAdapter, this.haSigner);
167
- } else {
168
- this.keyStore = newAdapter;
169
- }
170
- this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
226
+ this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
227
+ this.validationService = new ValidationService(this.keyStore, this.getSignatureContext(), this.log.createChild('validation-service'));
171
228
  }
172
229
  async start() {
173
230
  if (this.epochCacheUpdateLoop.isRunning()) {
@@ -200,7 +257,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
200
257
  // The checkpoint is received as CheckpointProposalCore since the lastBlock is extracted
201
258
  // and processed separately via the block handler above.
202
259
  const checkpointHandler = (checkpoint, proposalSender)=>this.attestToCheckpointProposal(checkpoint, proposalSender);
203
- this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
260
+ this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
204
261
  // Duplicate proposal handler - triggers slashing for equivocation
205
262
  this.p2pClient.registerDuplicateProposalCallback((info)=>{
206
263
  this.handleDuplicateProposal(info);
@@ -209,6 +266,9 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
209
266
  this.p2pClient.registerDuplicateAttestationCallback((info)=>{
210
267
  this.handleDuplicateAttestation(info);
211
268
  });
269
+ this.p2pClient.registerCheckpointAttestationCallback((attestation)=>{
270
+ this.handleCheckpointAttestation(attestation);
271
+ });
212
272
  const myAddresses = this.getValidatorAddresses();
213
273
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
214
274
  await this.p2pClient.addReqRespSubProtocol(ReqRespSubProtocol.AUTH, this.handleAuthRequest.bind(this));
@@ -248,11 +308,8 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
248
308
  txHashes: proposal.txHashes.map((t)=>t.toString()),
249
309
  fishermanMode: this.config.fishermanMode || false
250
310
  });
251
- // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
252
- // In fisherman mode, we always reexecute to validate proposals.
253
- const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
254
- const shouldReexecute = fishermanMode || slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute || partOfCommittee && validatorReexecute || alwaysReexecuteBlockProposals || this.blobClient.canUpload();
255
- const validationResult = await this.proposalHandler.handleBlockProposal(proposal, proposalSender, !!shouldReexecute && !escapeHatchOpen);
311
+ // Reexecute outside the escape hatch so slashing observers can detect invalid proposals even when penalties are 0.
312
+ const validationResult = await this.proposalHandler.handleBlockProposal(proposal, proposalSender, !escapeHatchOpen);
256
313
  if (!validationResult.isValid) {
257
314
  const reason = validationResult.reason || 'unknown';
258
315
  this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
@@ -270,10 +327,14 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
270
327
  // Node issues so we can't validate
271
328
  this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
272
329
  }
273
- // Slash invalid block proposals (can happen even when not in committee)
274
- if (!escapeHatchOpen && validationResult.reason && SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason) && slashBroadcastedInvalidBlockPenalty > 0n) {
275
- this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo);
330
+ if (!escapeHatchOpen && validationResult.reason && SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)) {
331
+ this.log.info(`Detected invalid block proposal offense`, {
332
+ ...proposalInfo,
333
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
334
+ offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL)
335
+ });
276
336
  this.slashInvalidBlock(proposal);
337
+ this.markInvalidProposalSlot(proposal.slotNumber);
277
338
  }
278
339
  return false;
279
340
  }
@@ -295,42 +356,50 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
295
356
  * the lastBlock is extracted and processed separately via the block handler.
296
357
  * @returns Checkpoint attestations if valid, undefined otherwise
297
358
  */ async attestToCheckpointProposal(proposal, _proposalSender) {
298
- const slotNumber = proposal.slotNumber;
359
+ const proposalSlotNumber = proposal.slotNumber;
299
360
  const proposer = proposal.getSender();
300
361
  // If escape hatch is open for this slot's epoch, do not attest.
301
- if (await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber)) {
302
- this.log.warn(`Escape hatch open for slot ${slotNumber}, skipping checkpoint attestation handling`);
362
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
363
+ this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
364
+ return undefined;
365
+ }
366
+ // Early-out for equivocation: refuses if we've already attested to a higher slot.
367
+ if (!this.shouldAttestToSlot(proposalSlotNumber)) {
303
368
  return undefined;
304
369
  }
305
370
  // Ignore proposals from ourselves (may happen in HA setups)
306
371
  if (proposer && this.getValidatorAddresses().some((addr)=>addr.equals(proposer))) {
307
- this.log.debug(`Ignoring block proposal from self for slot ${slotNumber}`, {
372
+ this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
308
373
  proposer: proposer.toString(),
309
- slotNumber
374
+ proposalSlotNumber
310
375
  });
311
376
  return undefined;
312
377
  }
313
378
  // Check that I have any address in the committee where this checkpoint will land before attesting
314
- const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
379
+ const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
315
380
  const partOfCommittee = inCommittee.length > 0;
316
381
  const proposalInfo = {
317
- slotNumber,
382
+ proposalSlotNumber,
318
383
  archive: proposal.archive.toString(),
319
384
  proposer: proposer?.toString()
320
385
  };
321
- this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
386
+ this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
322
387
  ...proposalInfo,
323
388
  fishermanMode: this.config.fishermanMode || false
324
389
  });
325
- // Validate the checkpoint proposal and upload blobs (unless skipCheckpointProposalValidation is set)
390
+ // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
391
+ // Uses the cached result from the all-nodes callback if available (avoids double validation).
392
+ let checkpointNumber;
326
393
  if (this.config.skipCheckpointProposalValidation) {
327
- this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
394
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
395
+ checkpointNumber = CheckpointNumber(0);
328
396
  } else {
329
397
  const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
330
398
  if (!validationResult.isValid) {
331
399
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
332
400
  return undefined;
333
401
  }
402
+ checkpointNumber = validationResult.checkpointNumber;
334
403
  }
335
404
  // Check that I have any address in current committee before attesting
336
405
  // In fisherman mode, we still create attestations for validation even if not in committee
@@ -339,14 +408,14 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
339
408
  return undefined;
340
409
  }
341
410
  // Provided all of the above checks pass, we can attest to the proposal
342
- this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, {
411
+ this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`, {
343
412
  ...proposalInfo,
344
413
  inCommittee: partOfCommittee,
345
414
  fishermanMode: this.config.fishermanMode || false
346
415
  });
347
416
  this.metrics.incSuccessfulAttestations(inCommittee.length);
348
417
  // Track epoch participation per attester: count each (attester, epoch) pair at most once
349
- const proposalEpoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants());
418
+ const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
350
419
  for (const attester of inCommittee){
351
420
  const key = attester.toString();
352
421
  const lastEpoch = this.lastAttestedEpochByAttester.get(key);
@@ -371,13 +440,13 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
371
440
  }
372
441
  if (this.config.fishermanMode) {
373
442
  // bail out early and don't save attestations to the pool in fisherman mode
374
- this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
443
+ this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
375
444
  ...proposalInfo,
376
445
  attestors: attestors.map((a)=>a.toString())
377
446
  });
378
447
  return undefined;
379
448
  }
380
- return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
449
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
381
450
  }
382
451
  /**
383
452
  * Checks if we should attest to a slot based on equivocation prevention rules.
@@ -394,17 +463,44 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
394
463
  }
395
464
  return true;
396
465
  }
397
- async createCheckpointAttestationsFromProposal(proposal, attestors = []) {
466
+ async createCheckpointAttestationsFromProposal(proposal, attestors = [], checkpointNumber) {
398
467
  // Equivocation check: must happen right before signing to minimize the race window
399
468
  if (!this.shouldAttestToSlot(proposal.slotNumber)) {
400
469
  return undefined;
401
470
  }
402
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
471
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
403
472
  // Track the proposal we attested to (to prevent equivocation)
404
473
  this.lastAttestedProposal = proposal;
405
474
  await this.p2pClient.addOwnCheckpointAttestations(attestations);
406
475
  return attestations;
407
476
  }
477
+ /**
478
+ * Uploads blobs for a checkpoint to the filestore (fire and forget).
479
+ */ async uploadBlobsForCheckpoint(proposal, proposalInfo) {
480
+ try {
481
+ const lastBlockHeader = (await this.blockSource.getBlockData({
482
+ archive: proposal.archive
483
+ }))?.header;
484
+ if (!lastBlockHeader) {
485
+ this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
486
+ return;
487
+ }
488
+ const blocks = await this.blockSource.getBlocksForSlot(proposal.slotNumber);
489
+ if (blocks.length === 0) {
490
+ this.log.warn(`No blocks found for blob upload`, proposalInfo);
491
+ return;
492
+ }
493
+ const blobFields = blocks.flatMap((b)=>b.toBlobFields());
494
+ const blobs = await getBlobsPerL1Block(blobFields);
495
+ await this.blobClient.sendBlobsToFilestore(blobs);
496
+ this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
497
+ ...proposalInfo,
498
+ numBlobs: blobs.length
499
+ });
500
+ } catch (err) {
501
+ this.log.warn(`Failed to upload blobs for checkpoint: ${err}`, proposalInfo);
502
+ }
503
+ }
408
504
  slashInvalidBlock(proposal) {
409
505
  const proposer = proposal.getSender();
410
506
  // Skip if signature is invalid (shouldn't happen since we validate earlier)
@@ -412,11 +508,6 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
412
508
  this.log.warn(`Cannot slash proposal with invalid signature`);
413
509
  return;
414
510
  }
415
- // Trim the set if it's too big.
416
- if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
417
- // remove oldest proposer. `values` is guaranteed to be in insertion order.
418
- this.proposersOfInvalidBlocks.delete(this.proposersOfInvalidBlocks.values().next().value);
419
- }
420
511
  this.proposersOfInvalidBlocks.add(proposer.toString());
421
512
  this.emit(WANT_TO_SLASH_EVENT, [
422
513
  {
@@ -427,17 +518,95 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
427
518
  }
428
519
  ]);
429
520
  }
521
+ handleInvalidCheckpointProposal(proposal, result, proposalInfo) {
522
+ if (!SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
523
+ return;
524
+ }
525
+ this.markInvalidProposalSlot(proposal.slotNumber);
526
+ if (this.slashInvalidCheckpointProposal(proposal)) {
527
+ this.log.info(`Detected invalid checkpoint proposal offense`, {
528
+ ...proposalInfo,
529
+ reason: result.reason,
530
+ amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
531
+ offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL)
532
+ });
533
+ }
534
+ }
535
+ slashInvalidCheckpointProposal(proposal) {
536
+ const proposer = proposal.getSender();
537
+ if (!proposer) {
538
+ this.log.warn(`Cannot slash checkpoint proposal with invalid signature`, {
539
+ slotNumber: proposal.slotNumber,
540
+ archive: proposal.archive.toString()
541
+ });
542
+ return false;
543
+ }
544
+ const offenseType = OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL;
545
+ const offenseKey = `${proposer.toString()}:${offenseType}:${proposal.slotNumber}`;
546
+ if (!this.invalidCheckpointProposalOffenseKeys.addIfAbsent(offenseKey)) {
547
+ return false;
548
+ }
549
+ this.emit(WANT_TO_SLASH_EVENT, [
550
+ {
551
+ validator: proposer,
552
+ amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
553
+ offenseType,
554
+ epochOrSlot: BigInt(proposal.slotNumber)
555
+ }
556
+ ]);
557
+ return true;
558
+ }
559
+ markInvalidProposalSlot(slotNumber) {
560
+ this.slotsWithInvalidProposals.add(slotNumber);
561
+ }
562
+ handleCheckpointAttestation(attestation) {
563
+ const slotNumber = attestation.slotNumber;
564
+ if (!this.slotsWithInvalidProposals.has(slotNumber) || this.slotsWithProposalEquivocation.has(slotNumber)) {
565
+ return;
566
+ }
567
+ const attester = attestation.getSender();
568
+ if (!attester) {
569
+ this.log.warn(`Cannot slash checkpoint attestation with invalid signature`, {
570
+ slotNumber,
571
+ archive: attestation.archive.toString()
572
+ });
573
+ return;
574
+ }
575
+ this.slashAttestedToInvalidCheckpointProposal(slotNumber, attester);
576
+ }
577
+ slashAttestedToInvalidCheckpointProposal(slotNumber, attester) {
578
+ const offenseKey = `${attester.toString()}:${OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL}:${slotNumber}`;
579
+ if (!this.badAttestationOffenseKeys.addIfAbsent(offenseKey)) {
580
+ return;
581
+ }
582
+ this.log.info(`Detected attestation to invalid checkpoint proposal offense`, {
583
+ attester: attester.toString(),
584
+ slotNumber,
585
+ amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
586
+ offenseType: getOffenseTypeName(OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL)
587
+ });
588
+ this.emit(WANT_TO_SLASH_EVENT, [
589
+ {
590
+ validator: attester,
591
+ amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
592
+ offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
593
+ epochOrSlot: BigInt(slotNumber)
594
+ }
595
+ ]);
596
+ }
430
597
  /**
431
598
  * Handle detection of a duplicate proposal (equivocation).
432
599
  * Emits a slash event when a proposer sends multiple proposals for the same position.
433
600
  */ handleDuplicateProposal(info) {
434
601
  const { slot, proposer, type } = info;
435
- this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
602
+ this.slotsWithProposalEquivocation.add(slot);
603
+ this.log.info(`Detected duplicate ${type} proposal offense from ${proposer.toString()} at slot ${slot}`, {
436
604
  proposer: proposer.toString(),
437
605
  slot,
438
- type
606
+ type,
607
+ amount: this.config.slashDuplicateProposalPenalty,
608
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_PROPOSAL)
439
609
  });
440
- // Emit slash event
441
610
  this.emit(WANT_TO_SLASH_EVENT, [
442
611
  {
443
612
  validator: proposer,
@@ -446,15 +615,23 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
446
615
  epochOrSlot: BigInt(slot)
447
616
  }
448
617
  ]);
618
+ this.emit(WANT_TO_CLEAR_SLASH_EVENT, [
619
+ {
620
+ offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
621
+ epochOrSlot: BigInt(slot)
622
+ }
623
+ ]);
449
624
  }
450
625
  /**
451
626
  * Handle detection of a duplicate attestation (equivocation).
452
627
  * Emits a slash event when an attester signs attestations for different proposals at the same slot.
453
628
  */ handleDuplicateAttestation(info) {
454
629
  const { slot, attester } = info;
455
- this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
630
+ this.log.info(`Detected duplicate attestation offense from ${attester.toString()} at slot ${slot}`, {
456
631
  attester: attester.toString(),
457
- slot
632
+ slot,
633
+ amount: this.config.slashDuplicateAttestationPenalty,
634
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_ATTESTATION)
458
635
  });
459
636
  this.emit(WANT_TO_SLASH_EVENT, [
460
637
  {
@@ -465,7 +642,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
465
642
  }
466
643
  ]);
467
644
  }
468
- async createBlockProposal(blockHeader, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, options = {}) {
645
+ async createBlockProposal(blockHeader, checkpointNumber, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, options = {}) {
469
646
  // Validate that we're not creating a proposal for an older or equal position
470
647
  if (this.lastProposedBlock) {
471
648
  const lastSlot = this.lastProposedBlock.slotNumber;
@@ -476,14 +653,14 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
476
653
  }
477
654
  }
478
655
  this.log.info(`Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`);
479
- const newProposal = await this.validationService.createBlockProposal(blockHeader, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, {
656
+ const newProposal = await this.validationService.createBlockProposal(blockHeader, checkpointNumber, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, {
480
657
  ...options,
481
- broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal
658
+ broadcastInvalidBlockProposal: options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal
482
659
  });
483
660
  this.lastProposedBlock = newProposal;
484
661
  return newProposal;
485
662
  }
486
- async createCheckpointProposal(checkpointHeader, archive, feeAssetPriceModifier, lastBlockInfo, proposerAddress, options = {}) {
663
+ async createCheckpointProposal(checkpointHeader, archive, checkpointNumber, feeAssetPriceModifier, lastBlockProposal, proposerAddress, options = {}) {
487
664
  // Validate that we're not creating a proposal for an older or equal slot
488
665
  if (this.lastProposedCheckpoint) {
489
666
  const lastSlot = this.lastProposedCheckpoint.slotNumber;
@@ -493,23 +670,30 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
493
670
  }
494
671
  }
495
672
  this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
496
- const newProposal = await this.validationService.createCheckpointProposal(checkpointHeader, archive, feeAssetPriceModifier, lastBlockInfo, proposerAddress, options);
673
+ const newProposal = await this.validationService.createCheckpointProposal(checkpointHeader, archive, checkpointNumber, feeAssetPriceModifier, lastBlockProposal, proposerAddress, options);
497
674
  this.lastProposedCheckpoint = newProposal;
675
+ // Self-record this slot's outcome on the re-execution tracker. Proposers don't run their
676
+ // own proposals through `handleCheckpointProposal`, so without this call the proposer's
677
+ // sentinel would see no outcome for slots it proposed and would mis-attribute itself as
678
+ // inactive. We pass the locally-computed `archive` (not `newProposal.archive`, which may
679
+ // be intentionally corrupted under test-only flags); from the proposer's local-view
680
+ // perspective the work it just completed is valid by definition.
681
+ this.proposalHandler.recordOwnCheckpointProposalAsValid(checkpointHeader.slotNumber, archive, checkpointNumber);
498
682
  return newProposal;
499
683
  }
500
684
  async broadcastBlockProposal(proposal) {
501
685
  await this.p2pClient.broadcastProposal(proposal);
502
686
  }
503
- async signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber) {
504
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
687
+ async signAttestationsAndSigners(attestationsAndSigners, proposer, slot, checkpointNumber) {
688
+ return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, checkpointNumber);
505
689
  }
506
- async collectOwnAttestations(proposal) {
690
+ async collectOwnAttestations(proposal, checkpointNumber) {
507
691
  const slot = proposal.slotNumber;
508
692
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
509
693
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, {
510
694
  inCommittee
511
695
  });
512
- const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
696
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
513
697
  if (!attestations) {
514
698
  return [];
515
699
  }
@@ -521,7 +705,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
521
705
  });
522
706
  return attestations;
523
707
  }
524
- async collectAttestations(proposal, required, deadline) {
708
+ async collectAttestations(proposal, required, deadline, checkpointNumber) {
525
709
  // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
526
710
  const slot = proposal.slotNumber;
527
711
  this.log.debug(`Collecting ${required} attestations for slot ${slot} with deadline ${deadline.toISOString()}`);
@@ -529,28 +713,20 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
529
713
  this.log.error(`Deadline ${deadline.toISOString()} for collecting ${required} attestations for slot ${slot} is in the past`);
530
714
  throw new AttestationTimeoutError(0, required, slot);
531
715
  }
532
- await this.collectOwnAttestations(proposal);
533
- const proposalId = proposal.archive.toString();
716
+ await this.collectOwnAttestations(proposal, checkpointNumber);
717
+ const proposalPayloadHash = proposal.getPayloadHash();
534
718
  const myAddresses = this.getValidatorAddresses();
535
719
  let attestations = [];
536
720
  while(true){
537
- // Filter out attestations with a mismatching archive. This should NOT happen since we have verified
538
- // the proposer signature (ie our own) before accepting the attestation into the pool via the p2p client.
539
- const collectedAttestations = (await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalId)).filter((attestation)=>{
540
- if (!attestation.archive.equals(proposal.archive)) {
541
- this.log.warn(`Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`, {
542
- attestationArchive: attestation.archive.toString(),
543
- proposalArchive: proposal.archive.toString()
544
- });
545
- return false;
546
- }
547
- return true;
548
- });
721
+ // The pool already filters by proposal payload hash; if any attestation slips through with a
722
+ // mismatched payload hash, drop it defensively. Equivocations are emitted as separate slash
723
+ // events from libp2p_service.
724
+ const collectedAttestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalPayloadHash);
549
725
  // Log new attestations we collected
550
726
  const oldSenders = attestations.map((attestation)=>attestation.getSender());
551
727
  for (const collected of collectedAttestations){
552
728
  const collectedSender = collected.getSender();
553
- // Skip attestations with invalid signatures
729
+ // Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
554
730
  if (!collectedSender) {
555
731
  this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
556
732
  continue;