@aztec/validator-client 0.0.1-commit.42ee6df9b → 0.0.1-commit.431c48d

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,11 +1,14 @@
1
1
  import { getBlobsPerL1Block } from '@aztec/blob-lib';
2
+ import { CheckpointNumber } from '@aztec/foundation/branded-types';
3
+ import { FifoSet } from '@aztec/foundation/fifo-set';
2
4
  import { createLogger } from '@aztec/foundation/log';
3
5
  import { RunningPromise } from '@aztec/foundation/running-promise';
4
6
  import { sleep } from '@aztec/foundation/sleep';
5
7
  import { DateProvider } from '@aztec/foundation/timer';
6
- import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
7
- import { OffenseType, WANT_TO_SLASH_EVENT } from '@aztec/slasher';
8
+ import { AuthRequest, AuthResponse, ReqRespSubProtocol } from '@aztec/p2p';
9
+ import { OffenseType, WANT_TO_CLEAR_SLASH_EVENT, WANT_TO_SLASH_EVENT, getOffenseTypeName } from '@aztec/slasher';
8
10
  import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
11
+ import { ConsensusTimetable } from '@aztec/stdlib/timetable';
9
12
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
10
13
  import { getTelemetryClient } from '@aztec/telemetry-client';
11
14
  import { createHASigner, createLocalSignerWithProtection, createSignerFromSharedDb } from '@aztec/validator-ha-signer/factory';
@@ -15,15 +18,12 @@ import { ValidationService } from './duties/validation_service.js';
15
18
  import { HAKeyStore } from './key_store/ha_key_store.js';
16
19
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
17
20
  import { ValidatorMetrics } from './metrics.js';
18
- import { ProposalHandler } from './proposal_handler.js';
21
+ import { ProposalHandler, SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT, SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT } from './proposal_handler.js';
19
22
  // We maintain a set of proposers who have proposed invalid blocks.
20
23
  // Just cap the set to avoid unbounded growth.
21
24
  const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
22
- // What errors from the block proposal handler result in slashing
23
- const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
24
- 'state_mismatch',
25
- 'failed_txs'
26
- ];
25
+ const MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS = 1000;
26
+ const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
27
27
  /**
28
28
  * Validator Client
29
29
  */ export class ValidatorClient extends EventEmitter {
@@ -51,14 +51,18 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
51
51
  epochCacheUpdateLoop;
52
52
  /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */ lastAttestedEpochByAttester;
53
53
  proposersOfInvalidBlocks;
54
+ invalidCheckpointProposalOffenseKeys;
55
+ oversizedProposalOffenseKeys;
56
+ badAttestationOffenseKeys;
54
57
  /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */ lastAttestedProposal;
55
58
  constructor(keyStore, epochCache, p2pClient, proposalHandler, blockSource, checkpointsBuilder, worldState, l1ToL2MessageSource, config, blobClient, slashingProtectionSigner, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), log = createLogger('validator')){
56
- 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 = new Set();
59
+ 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.invalidCheckpointProposalOffenseKeys = FifoSet.withLimit(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS), this.oversizedProposalOffenseKeys = FifoSet.withLimit(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS), this.badAttestationOffenseKeys = FifoSet.withLimit(MAX_TRACKED_BAD_ATTESTATIONS);
57
60
  // Create child logger with fisherman prefix if in fisherman mode
58
61
  this.log = config.fishermanMode ? log.createChild('[FISHERMAN]') : log;
59
62
  this.tracer = telemetry.getTracer('Validator');
60
63
  this.metrics = new ValidatorMetrics(telemetry);
61
- this.validationService = new ValidationService(keyStore, this.log.createChild('validation-service'));
64
+ this.validationService = new ValidationService(keyStore, this.getSignatureContext(), this.log.createChild('validation-service'));
65
+ this.proposalHandler.setCheckpointProposalValidationFailureCallback((proposal, result, proposalInfo)=>this.handleInvalidCheckpointProposal(proposal, result, proposalInfo));
62
66
  // Refresh epoch cache every second to trigger alert if participation in committee changes
63
67
  this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
64
68
  const myAddresses = this.getValidatorAddresses();
@@ -108,13 +112,13 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
108
112
  this.log.error(`Error updating epoch committee`, err);
109
113
  }
110
114
  }
111
- static async new(config, checkpointsBuilder, worldState, epochCache, p2pClient, blockSource, l1ToL2MessageSource, txProvider, keyStoreManager, blobClient, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), slashingProtectionDb) {
115
+ static async new(config, checkpointsBuilder, worldState, epochCache, p2pClient, blockSource, l1ToL2MessageSource, txProvider, keyStoreManager, blobClient, reexecutionTracker, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), slashingProtectionDb) {
112
116
  const metrics = new ValidatorMetrics(telemetry);
113
- const blockProposalValidator = new BlockProposalValidator(epochCache, {
114
- txsPermitted: !config.disableTransactions,
115
- maxTxsPerBlock: config.validateMaxTxsPerBlock
117
+ const consensusTimetable = new ConsensusTimetable({
118
+ l1Constants: epochCache.getL1Constants(),
119
+ blockDuration: config.blockDurationMs / 1000
116
120
  });
117
- const proposalHandler = new ProposalHandler(checkpointsBuilder, worldState, blockSource, l1ToL2MessageSource, txProvider, blockProposalValidator, epochCache, config, blobClient, metrics, dateProvider, telemetry);
121
+ const proposalHandler = new ProposalHandler(checkpointsBuilder, worldState, blockSource, l1ToL2MessageSource, txProvider, epochCache, consensusTimetable, config, blobClient, reexecutionTracker, metrics, dateProvider, telemetry, undefined);
118
122
  const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
119
123
  let slashingProtectionSigner;
120
124
  if (slashingProtectionDb) {
@@ -155,6 +159,12 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
155
159
  signWithAddress(addr, msg, context) {
156
160
  return this.keyStore.signTypedDataWithAddress(addr, msg, context);
157
161
  }
162
+ getSignatureContext() {
163
+ return {
164
+ chainId: this.config.l1ChainId,
165
+ rollupAddress: this.config.rollupAddress
166
+ };
167
+ }
158
168
  getCoinbaseForAttestor(attestor) {
159
169
  return this.keyStore.getCoinbaseAddress(attestor);
160
170
  }
@@ -164,16 +174,23 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
164
174
  getConfig() {
165
175
  return this.config;
166
176
  }
177
+ hasProposalEquivocation(slotNumber) {
178
+ return this.proposalHandler.hasProposalEquivocation(slotNumber);
179
+ }
180
+ hasInvalidProposals(slotNumber) {
181
+ return this.proposalHandler.hasInvalidProposals(slotNumber);
182
+ }
167
183
  updateConfig(config) {
168
184
  this.config = {
169
185
  ...this.config,
170
186
  ...config
171
187
  };
188
+ this.proposalHandler.updateConfig(config);
172
189
  }
173
190
  reloadKeystore(newManager) {
174
191
  const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
175
192
  this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
176
- this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
193
+ this.validationService = new ValidationService(this.keyStore, this.getSignatureContext(), this.log.createChild('validation-service'));
177
194
  }
178
195
  async start() {
179
196
  if (this.epochCacheUpdateLoop.isRunning()) {
@@ -211,10 +228,17 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
211
228
  this.p2pClient.registerDuplicateProposalCallback((info)=>{
212
229
  this.handleDuplicateProposal(info);
213
230
  });
231
+ // Oversized proposal handler - triggers slashing for proposals beyond the per-checkpoint block limit
232
+ this.p2pClient.registerOversizedProposalCallback((info)=>{
233
+ this.handleOversizedProposal(info);
234
+ });
214
235
  // Duplicate attestation handler - triggers slashing for attestation equivocation
215
236
  this.p2pClient.registerDuplicateAttestationCallback((info)=>{
216
237
  this.handleDuplicateAttestation(info);
217
238
  });
239
+ this.p2pClient.registerCheckpointAttestationCallback((attestation)=>{
240
+ this.handleCheckpointAttestation(attestation);
241
+ });
218
242
  const myAddresses = this.getValidatorAddresses();
219
243
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
220
244
  await this.p2pClient.addReqRespSubProtocol(ReqRespSubProtocol.AUTH, this.handleAuthRequest.bind(this));
@@ -254,11 +278,8 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
254
278
  txHashes: proposal.txHashes.map((t)=>t.toString()),
255
279
  fishermanMode: this.config.fishermanMode || false
256
280
  });
257
- // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
258
- // In fisherman mode, we always reexecute to validate proposals.
259
- const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
260
- const shouldReexecute = fishermanMode || slashBroadcastedInvalidBlockPenalty > 0n || partOfCommittee || alwaysReexecuteBlockProposals || this.blobClient.canUpload();
261
- const validationResult = await this.proposalHandler.handleBlockProposal(proposal, proposalSender, !!shouldReexecute && !escapeHatchOpen);
281
+ // Reexecute outside the escape hatch so slashing observers can detect invalid proposals even when penalties are 0.
282
+ const validationResult = await this.proposalHandler.handleBlockProposal(proposal, proposalSender, !escapeHatchOpen);
262
283
  if (!validationResult.isValid) {
263
284
  const reason = validationResult.reason || 'unknown';
264
285
  this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
@@ -268,7 +289,9 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
268
289
  'state_mismatch',
269
290
  'failed_txs',
270
291
  'in_hash_mismatch',
271
- 'parent_block_wrong_slot'
292
+ 'parent_block_wrong_slot',
293
+ 'duplicate_txs',
294
+ 'invalid_embedded_txs'
272
295
  ];
273
296
  if (badProposalReasons.includes(reason)) {
274
297
  this.metrics.incFailedAttestationsBadProposal(1, reason, partOfCommittee);
@@ -276,10 +299,14 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
276
299
  // Node issues so we can't validate
277
300
  this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
278
301
  }
279
- // Slash invalid block proposals (can happen even when not in committee)
280
- if (!escapeHatchOpen && validationResult.reason && SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason) && slashBroadcastedInvalidBlockPenalty > 0n) {
281
- this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo);
302
+ if (!escapeHatchOpen && validationResult.reason && SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)) {
303
+ this.log.info(`Detected invalid block proposal offense`, {
304
+ ...proposalInfo,
305
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
306
+ offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL)
307
+ });
282
308
  this.slashInvalidBlock(proposal);
309
+ this.markInvalidProposalSlot(proposal.slotNumber);
283
310
  }
284
311
  return false;
285
312
  }
@@ -308,9 +335,13 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
308
335
  this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
309
336
  return undefined;
310
337
  }
338
+ // Early-out for equivocation: refuses if we've already attested to a higher slot.
339
+ if (!this.shouldAttestToSlot(proposalSlotNumber)) {
340
+ return undefined;
341
+ }
311
342
  // Ignore proposals from ourselves (may happen in HA setups)
312
343
  if (proposer && this.getValidatorAddresses().some((addr)=>addr.equals(proposer))) {
313
- this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
344
+ this.log.debug(`Not attesting to block proposal from self for slot ${proposalSlotNumber}`, {
314
345
  proposer: proposer.toString(),
315
346
  proposalSlotNumber
316
347
  });
@@ -330,14 +361,17 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
330
361
  });
331
362
  // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
332
363
  // Uses the cached result from the all-nodes callback if available (avoids double validation).
364
+ let checkpointNumber;
333
365
  if (this.config.skipCheckpointProposalValidation) {
334
366
  this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
367
+ checkpointNumber = CheckpointNumber(0);
335
368
  } else {
336
369
  const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
337
370
  if (!validationResult.isValid) {
338
371
  this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
339
372
  return undefined;
340
373
  }
374
+ checkpointNumber = validationResult.checkpointNumber;
341
375
  }
342
376
  // Check that I have any address in current committee before attesting
343
377
  // In fisherman mode, we still create attestations for validation even if not in committee
@@ -384,7 +418,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
384
418
  });
385
419
  return undefined;
386
420
  }
387
- return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
421
+ return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
388
422
  }
389
423
  /**
390
424
  * Checks if we should attest to a slot based on equivocation prevention rules.
@@ -401,12 +435,12 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
401
435
  }
402
436
  return true;
403
437
  }
404
- async createCheckpointAttestationsFromProposal(proposal, attestors = []) {
438
+ async createCheckpointAttestationsFromProposal(proposal, attestors = [], checkpointNumber) {
405
439
  // Equivocation check: must happen right before signing to minimize the race window
406
440
  if (!this.shouldAttestToSlot(proposal.slotNumber)) {
407
441
  return undefined;
408
442
  }
409
- const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
443
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
410
444
  // Track the proposal we attested to (to prevent equivocation)
411
445
  this.lastAttestedProposal = proposal;
412
446
  await this.p2pClient.addOwnCheckpointAttestations(attestations);
@@ -416,7 +450,9 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
416
450
  * Uploads blobs for a checkpoint to the filestore (fire and forget).
417
451
  */ async uploadBlobsForCheckpoint(proposal, proposalInfo) {
418
452
  try {
419
- const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
453
+ const lastBlockHeader = (await this.blockSource.getBlockData({
454
+ archive: proposal.archive
455
+ }))?.header;
420
456
  if (!lastBlockHeader) {
421
457
  this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
422
458
  return;
@@ -444,11 +480,6 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
444
480
  this.log.warn(`Cannot slash proposal with invalid signature`);
445
481
  return;
446
482
  }
447
- // Trim the set if it's too big.
448
- if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
449
- // remove oldest proposer. `values` is guaranteed to be in insertion order.
450
- this.proposersOfInvalidBlocks.delete(this.proposersOfInvalidBlocks.values().next().value);
451
- }
452
483
  this.proposersOfInvalidBlocks.add(proposer.toString());
453
484
  this.emit(WANT_TO_SLASH_EVENT, [
454
485
  {
@@ -459,17 +490,122 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
459
490
  }
460
491
  ]);
461
492
  }
493
+ handleInvalidCheckpointProposal(proposal, result, proposalInfo) {
494
+ if (!SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
495
+ return;
496
+ }
497
+ // The slot is already marked invalid by the all-nodes checkpoint handler that invokes this callback,
498
+ // so we only emit the proposer slash event here.
499
+ if (this.slashInvalidCheckpointProposal(proposal)) {
500
+ this.log.info(`Detected invalid checkpoint proposal offense`, {
501
+ ...proposalInfo,
502
+ reason: result.reason,
503
+ amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
504
+ offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL)
505
+ });
506
+ }
507
+ }
508
+ slashInvalidCheckpointProposal(proposal) {
509
+ const proposer = proposal.getSender();
510
+ if (!proposer) {
511
+ this.log.warn(`Cannot slash checkpoint proposal with invalid signature`, {
512
+ slotNumber: proposal.slotNumber,
513
+ archive: proposal.archive.toString()
514
+ });
515
+ return false;
516
+ }
517
+ const offenseType = OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL;
518
+ const offenseKey = `${proposer.toString()}:${offenseType}:${proposal.slotNumber}`;
519
+ if (!this.invalidCheckpointProposalOffenseKeys.addIfAbsent(offenseKey)) {
520
+ return false;
521
+ }
522
+ this.emit(WANT_TO_SLASH_EVENT, [
523
+ {
524
+ validator: proposer,
525
+ amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
526
+ offenseType,
527
+ epochOrSlot: BigInt(proposal.slotNumber)
528
+ }
529
+ ]);
530
+ return true;
531
+ }
532
+ markInvalidProposalSlot(slotNumber) {
533
+ this.proposalHandler.markInvalidProposalSlot(slotNumber);
534
+ }
535
+ handleCheckpointAttestation(attestation) {
536
+ const slotNumber = attestation.slotNumber;
537
+ if (!this.proposalHandler.hasInvalidProposals(slotNumber) || this.proposalHandler.hasProposalEquivocation(slotNumber)) {
538
+ return;
539
+ }
540
+ const attester = attestation.getSender();
541
+ if (!attester) {
542
+ this.log.warn(`Cannot slash checkpoint attestation with invalid signature`, {
543
+ slotNumber,
544
+ archive: attestation.archive.toString()
545
+ });
546
+ return;
547
+ }
548
+ this.slashAttestedToInvalidCheckpointProposal(slotNumber, attester);
549
+ }
550
+ slashAttestedToInvalidCheckpointProposal(slotNumber, attester) {
551
+ const offenseKey = `${attester.toString()}:${OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL}:${slotNumber}`;
552
+ if (!this.badAttestationOffenseKeys.addIfAbsent(offenseKey)) {
553
+ return;
554
+ }
555
+ this.log.info(`Detected attestation to invalid checkpoint proposal offense`, {
556
+ attester: attester.toString(),
557
+ slotNumber,
558
+ amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
559
+ offenseType: getOffenseTypeName(OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL)
560
+ });
561
+ this.emit(WANT_TO_SLASH_EVENT, [
562
+ {
563
+ validator: attester,
564
+ amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
565
+ offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
566
+ epochOrSlot: BigInt(slotNumber)
567
+ }
568
+ ]);
569
+ }
570
+ /**
571
+ * Handle detection of an oversized block proposal: one whose index within its checkpoint lands at or
572
+ * beyond the consensus per-checkpoint block limit. A single signed proposal at an illegal index is
573
+ * self-contained evidence, so emit an invalid-block-proposal slash event for the proposer, deduped per
574
+ * (proposer, slot) since the p2p layer reports every oversized proposal it stores.
575
+ */ handleOversizedProposal(info) {
576
+ const { slot, proposer } = info;
577
+ const offenseType = OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL;
578
+ if (!this.oversizedProposalOffenseKeys.addIfAbsent(`${proposer.toString()}:${offenseType}:${slot}`)) {
579
+ return;
580
+ }
581
+ this.log.info(`Detected oversized block proposal offense from ${proposer.toString()} at slot ${slot}`, {
582
+ proposer: proposer.toString(),
583
+ slot,
584
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
585
+ offenseType: getOffenseTypeName(offenseType)
586
+ });
587
+ this.emit(WANT_TO_SLASH_EVENT, [
588
+ {
589
+ validator: proposer,
590
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
591
+ offenseType,
592
+ epochOrSlot: BigInt(slot)
593
+ }
594
+ ]);
595
+ }
462
596
  /**
463
597
  * Handle detection of a duplicate proposal (equivocation).
464
598
  * Emits a slash event when a proposer sends multiple proposals for the same position.
465
599
  */ handleDuplicateProposal(info) {
466
600
  const { slot, proposer, type } = info;
467
- this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
601
+ this.proposalHandler.markProposalEquivocation(slot);
602
+ this.log.info(`Detected duplicate ${type} proposal offense from ${proposer.toString()} at slot ${slot}`, {
468
603
  proposer: proposer.toString(),
469
604
  slot,
470
- type
605
+ type,
606
+ amount: this.config.slashDuplicateProposalPenalty,
607
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_PROPOSAL)
471
608
  });
472
- // Emit slash event
473
609
  this.emit(WANT_TO_SLASH_EVENT, [
474
610
  {
475
611
  validator: proposer,
@@ -478,15 +614,23 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
478
614
  epochOrSlot: BigInt(slot)
479
615
  }
480
616
  ]);
617
+ this.emit(WANT_TO_CLEAR_SLASH_EVENT, [
618
+ {
619
+ offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
620
+ epochOrSlot: BigInt(slot)
621
+ }
622
+ ]);
481
623
  }
482
624
  /**
483
625
  * Handle detection of a duplicate attestation (equivocation).
484
626
  * Emits a slash event when an attester signs attestations for different proposals at the same slot.
485
627
  */ handleDuplicateAttestation(info) {
486
628
  const { slot, attester } = info;
487
- this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
629
+ this.log.info(`Detected duplicate attestation offense from ${attester.toString()} at slot ${slot}`, {
488
630
  attester: attester.toString(),
489
- slot
631
+ slot,
632
+ amount: this.config.slashDuplicateAttestationPenalty,
633
+ offenseType: getOffenseTypeName(OffenseType.DUPLICATE_ATTESTATION)
490
634
  });
491
635
  this.emit(WANT_TO_SLASH_EVENT, [
492
636
  {
@@ -497,7 +641,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
497
641
  }
498
642
  ]);
499
643
  }
500
- async createBlockProposal(blockHeader, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, options = {}) {
644
+ async createBlockProposal(blockHeader, checkpointNumber, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, options = {}) {
501
645
  // Validate that we're not creating a proposal for an older or equal position
502
646
  if (this.lastProposedBlock) {
503
647
  const lastSlot = this.lastProposedBlock.slotNumber;
@@ -508,14 +652,14 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
508
652
  }
509
653
  }
510
654
  this.log.info(`Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`);
511
- const newProposal = await this.validationService.createBlockProposal(blockHeader, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, {
655
+ const newProposal = await this.validationService.createBlockProposal(blockHeader, checkpointNumber, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, {
512
656
  ...options,
513
- broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal
657
+ broadcastInvalidBlockProposal: options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal
514
658
  });
515
659
  this.lastProposedBlock = newProposal;
516
660
  return newProposal;
517
661
  }
518
- async createCheckpointProposal(checkpointHeader, archive, feeAssetPriceModifier, lastBlockInfo, proposerAddress, options = {}) {
662
+ async createCheckpointProposal(checkpointHeader, archive, checkpointNumber, feeAssetPriceModifier, lastBlockProposal, proposerAddress, options = {}) {
519
663
  // Validate that we're not creating a proposal for an older or equal slot
520
664
  if (this.lastProposedCheckpoint) {
521
665
  const lastSlot = this.lastProposedCheckpoint.slotNumber;
@@ -525,23 +669,30 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
525
669
  }
526
670
  }
527
671
  this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
528
- const newProposal = await this.validationService.createCheckpointProposal(checkpointHeader, archive, feeAssetPriceModifier, lastBlockInfo, proposerAddress, options);
672
+ const newProposal = await this.validationService.createCheckpointProposal(checkpointHeader, archive, checkpointNumber, feeAssetPriceModifier, lastBlockProposal, proposerAddress, options);
529
673
  this.lastProposedCheckpoint = newProposal;
674
+ // Self-record this slot's outcome on the re-execution tracker. Proposers don't run their
675
+ // own proposals through `handleCheckpointProposal`, so without this call the proposer's
676
+ // sentinel would see no outcome for slots it proposed and would mis-attribute itself as
677
+ // inactive. We pass the locally-computed `archive` (not `newProposal.archive`, which may
678
+ // be intentionally corrupted under test-only flags); from the proposer's local-view
679
+ // perspective the work it just completed is valid by definition.
680
+ this.proposalHandler.recordOwnCheckpointProposalAsValid(checkpointHeader.slotNumber, archive, checkpointNumber);
530
681
  return newProposal;
531
682
  }
532
683
  async broadcastBlockProposal(proposal) {
533
684
  await this.p2pClient.broadcastProposal(proposal);
534
685
  }
535
- async signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber) {
536
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
686
+ async signAttestationsAndSigners(attestationsAndSigners, proposer, slot, checkpointNumber) {
687
+ return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, checkpointNumber);
537
688
  }
538
- async collectOwnAttestations(proposal) {
689
+ async collectOwnAttestations(proposal, checkpointNumber) {
539
690
  const slot = proposal.slotNumber;
540
691
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
541
692
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, {
542
693
  inCommittee
543
694
  });
544
- const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
695
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
545
696
  if (!attestations) {
546
697
  return [];
547
698
  }
@@ -553,7 +704,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
553
704
  });
554
705
  return attestations;
555
706
  }
556
- async collectAttestations(proposal, required, deadline) {
707
+ async collectAttestations(proposal, required, deadline, checkpointNumber) {
557
708
  // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
558
709
  const slot = proposal.slotNumber;
559
710
  this.log.debug(`Collecting ${required} attestations for slot ${slot} with deadline ${deadline.toISOString()}`);
@@ -561,28 +712,20 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
561
712
  this.log.error(`Deadline ${deadline.toISOString()} for collecting ${required} attestations for slot ${slot} is in the past`);
562
713
  throw new AttestationTimeoutError(0, required, slot);
563
714
  }
564
- await this.collectOwnAttestations(proposal);
565
- const proposalId = proposal.archive.toString();
715
+ await this.collectOwnAttestations(proposal, checkpointNumber);
716
+ const proposalPayloadHash = proposal.getPayloadHash();
566
717
  const myAddresses = this.getValidatorAddresses();
567
718
  let attestations = [];
568
719
  while(true){
569
- // Filter out attestations with a mismatching archive. This should NOT happen since we have verified
570
- // the proposer signature (ie our own) before accepting the attestation into the pool via the p2p client.
571
- const collectedAttestations = (await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalId)).filter((attestation)=>{
572
- if (!attestation.archive.equals(proposal.archive)) {
573
- this.log.warn(`Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`, {
574
- attestationArchive: attestation.archive.toString(),
575
- proposalArchive: proposal.archive.toString()
576
- });
577
- return false;
578
- }
579
- return true;
580
- });
720
+ // The pool already filters by proposal payload hash; if any attestation slips through with a
721
+ // mismatched payload hash, drop it defensively. Equivocations are emitted as separate slash
722
+ // events from libp2p_service.
723
+ const collectedAttestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalPayloadHash);
581
724
  // Log new attestations we collected
582
725
  const oldSenders = attestations.map((attestation)=>attestation.getSender());
583
726
  for (const collected of collectedAttestations){
584
727
  const collectedSender = collected.getSender();
585
- // Skip attestations with invalid signatures
728
+ // Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
586
729
  if (!collectedSender) {
587
730
  this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
588
731
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/validator-client",
3
- "version": "0.0.1-commit.42ee6df9b",
3
+ "version": "0.0.1-commit.431c48d",
4
4
  "main": "dest/index.js",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,30 +64,30 @@
64
64
  ]
65
65
  },
66
66
  "dependencies": {
67
- "@aztec/blob-client": "0.0.1-commit.42ee6df9b",
68
- "@aztec/blob-lib": "0.0.1-commit.42ee6df9b",
69
- "@aztec/constants": "0.0.1-commit.42ee6df9b",
70
- "@aztec/epoch-cache": "0.0.1-commit.42ee6df9b",
71
- "@aztec/ethereum": "0.0.1-commit.42ee6df9b",
72
- "@aztec/foundation": "0.0.1-commit.42ee6df9b",
73
- "@aztec/node-keystore": "0.0.1-commit.42ee6df9b",
74
- "@aztec/noir-protocol-circuits-types": "0.0.1-commit.42ee6df9b",
75
- "@aztec/p2p": "0.0.1-commit.42ee6df9b",
76
- "@aztec/protocol-contracts": "0.0.1-commit.42ee6df9b",
77
- "@aztec/prover-client": "0.0.1-commit.42ee6df9b",
78
- "@aztec/simulator": "0.0.1-commit.42ee6df9b",
79
- "@aztec/slasher": "0.0.1-commit.42ee6df9b",
80
- "@aztec/stdlib": "0.0.1-commit.42ee6df9b",
81
- "@aztec/telemetry-client": "0.0.1-commit.42ee6df9b",
82
- "@aztec/validator-ha-signer": "0.0.1-commit.42ee6df9b",
67
+ "@aztec/blob-client": "0.0.1-commit.431c48d",
68
+ "@aztec/blob-lib": "0.0.1-commit.431c48d",
69
+ "@aztec/constants": "0.0.1-commit.431c48d",
70
+ "@aztec/epoch-cache": "0.0.1-commit.431c48d",
71
+ "@aztec/ethereum": "0.0.1-commit.431c48d",
72
+ "@aztec/foundation": "0.0.1-commit.431c48d",
73
+ "@aztec/node-keystore": "0.0.1-commit.431c48d",
74
+ "@aztec/noir-protocol-circuits-types": "0.0.1-commit.431c48d",
75
+ "@aztec/p2p": "0.0.1-commit.431c48d",
76
+ "@aztec/protocol-contracts": "0.0.1-commit.431c48d",
77
+ "@aztec/prover-client": "0.0.1-commit.431c48d",
78
+ "@aztec/simulator": "0.0.1-commit.431c48d",
79
+ "@aztec/slasher": "0.0.1-commit.431c48d",
80
+ "@aztec/stdlib": "0.0.1-commit.431c48d",
81
+ "@aztec/telemetry-client": "0.0.1-commit.431c48d",
82
+ "@aztec/validator-ha-signer": "0.0.1-commit.431c48d",
83
83
  "koa": "^2.16.1",
84
84
  "koa-router": "^13.1.1",
85
85
  "tslib": "^2.4.0",
86
86
  "viem": "npm:@aztec/viem@2.38.2"
87
87
  },
88
88
  "devDependencies": {
89
- "@aztec/archiver": "0.0.1-commit.42ee6df9b",
90
- "@aztec/world-state": "0.0.1-commit.42ee6df9b",
89
+ "@aztec/archiver": "0.0.1-commit.431c48d",
90
+ "@aztec/world-state": "0.0.1-commit.431c48d",
91
91
  "@electric-sql/pglite": "^0.3.14",
92
92
  "@jest/globals": "^30.0.0",
93
93
  "@types/jest": "^30.0.0",
@@ -14,7 +14,7 @@ import {
14
14
  PublicProcessor,
15
15
  createPublicTxSimulatorForBlockBuilding,
16
16
  } from '@aztec/simulator/server';
17
- import { L2Block } from '@aztec/stdlib/block';
17
+ import { type BlockHash, L2Block } from '@aztec/stdlib/block';
18
18
  import { Checkpoint } from '@aztec/stdlib/checkpoint';
19
19
  import type { ContractDataSource } from '@aztec/stdlib/contract';
20
20
  import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
@@ -219,10 +219,12 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder {
219
219
  if (opts.isBuildingProposal) {
220
220
  const remainingBlocks = Math.max(1, opts.maxBlocksPerCheckpoint - existingBlocks.length);
221
221
  const multiplier = opts.perBlockAllocationMultiplier;
222
+ // DA gas and blob fields use a higher multiplier so the largest contract class deploy fits a block.
223
+ const daMultiplier = opts.perBlockDAAllocationMultiplier ?? multiplier;
222
224
 
223
225
  cappedL2Gas = Math.min(cappedL2Gas, Math.ceil((remainingMana / remainingBlocks) * multiplier));
224
- cappedDAGas = Math.min(cappedDAGas, Math.ceil((remainingDAGas / remainingBlocks) * multiplier));
225
- cappedBlobFields = Math.min(cappedBlobFields, Math.ceil((maxBlobFieldsForTxs / remainingBlocks) * multiplier));
226
+ cappedDAGas = Math.min(cappedDAGas, Math.ceil((remainingDAGas / remainingBlocks) * daMultiplier));
227
+ cappedBlobFields = Math.min(cappedBlobFields, Math.ceil((maxBlobFieldsForTxs / remainingBlocks) * daMultiplier));
226
228
  cappedMaxTransactions = Math.min(cappedMaxTransactions, Math.ceil((remainingTxs / remainingBlocks) * multiplier));
227
229
  }
228
230
 
@@ -410,8 +412,17 @@ export class FullNodeCheckpointsBuilder implements ICheckpointsBuilder {
410
412
  );
411
413
  }
412
414
 
413
- /** Returns a fork of the world state at the given block number. */
414
- getFork(blockNumber: BlockNumber): Promise<MerkleTreeWriteOperations> {
415
+ /**
416
+ * Syncs world state to the given block number and returns a fork of it at that block.
417
+ *
418
+ * Syncing first is required: the block source (archiver) can already hold a block while world state
419
+ * still trails it, and forking a not-yet-applied block throws a raw "initialize from future block"
420
+ * tree error. syncImmediate blocks until world state reaches the block, or throws a typed error if it
421
+ * genuinely cannot. When `blockHash` is provided it is verified against the synced block, triggering a
422
+ * resync on mismatch (reorg detection).
423
+ */
424
+ async getFork(blockNumber: BlockNumber, blockHash?: BlockHash): Promise<MerkleTreeWriteOperations> {
425
+ await this.worldState.syncImmediate(blockNumber, blockHash);
415
426
  return this.worldState.fork(blockNumber);
416
427
  }
417
428
  }