@aztec-labs/p2p 6.0.0-rc.1 → 6.0.0-rc.2

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 (104) hide show
  1. package/dest/bootstrap/bootstrap.d.ts +2 -2
  2. package/dest/bootstrap/bootstrap.d.ts.map +1 -1
  3. package/dest/bootstrap/bootstrap.js +4 -2
  4. package/dest/client/factory.d.ts +3 -3
  5. package/dest/client/factory.d.ts.map +1 -1
  6. package/dest/client/factory.js +6 -6
  7. package/dest/client/interface.d.ts +1 -1
  8. package/dest/client/p2p_client.d.ts +1 -1
  9. package/dest/client/p2p_client.js +1 -1
  10. package/dest/config.d.ts +11 -1
  11. package/dest/config.d.ts.map +1 -1
  12. package/dest/config.js +16 -0
  13. package/dest/mem_pools/attestation_pool/attestation_pool.d.ts +15 -3
  14. package/dest/mem_pools/attestation_pool/attestation_pool.d.ts.map +1 -1
  15. package/dest/mem_pools/attestation_pool/attestation_pool.js +15 -0
  16. package/dest/mem_pools/attestation_pool/attestation_pool_test_suite.d.ts +1 -1
  17. package/dest/mem_pools/attestation_pool/attestation_pool_test_suite.d.ts.map +1 -1
  18. package/dest/mem_pools/attestation_pool/attestation_pool_test_suite.js +21 -0
  19. package/dest/mem_pools/tx_pool_v2/eviction/eviction_manager.d.ts +6 -2
  20. package/dest/mem_pools/tx_pool_v2/eviction/eviction_manager.d.ts.map +1 -1
  21. package/dest/mem_pools/tx_pool_v2/eviction/eviction_manager.js +9 -0
  22. package/dest/mem_pools/tx_pool_v2/eviction/insufficient_fee_per_gas_eviction_rule.d.ts +10 -8
  23. package/dest/mem_pools/tx_pool_v2/eviction/insufficient_fee_per_gas_eviction_rule.d.ts.map +1 -1
  24. package/dest/mem_pools/tx_pool_v2/eviction/insufficient_fee_per_gas_eviction_rule.js +22 -11
  25. package/dest/mem_pools/tx_pool_v2/eviction/interfaces.d.ts +6 -2
  26. package/dest/mem_pools/tx_pool_v2/eviction/interfaces.d.ts.map +1 -1
  27. package/dest/mem_pools/tx_pool_v2/eviction/interfaces.js +2 -1
  28. package/dest/mem_pools/tx_pool_v2/interfaces.d.ts +11 -5
  29. package/dest/mem_pools/tx_pool_v2/interfaces.d.ts.map +1 -1
  30. package/dest/mem_pools/tx_pool_v2/tx_pool_v2_impl.d.ts +1 -1
  31. package/dest/mem_pools/tx_pool_v2/tx_pool_v2_impl.d.ts.map +1 -1
  32. package/dest/mem_pools/tx_pool_v2/tx_pool_v2_impl.js +111 -44
  33. package/dest/msg_validators/attestation_validator/attestation_validator.d.ts +1 -1
  34. package/dest/msg_validators/attestation_validator/attestation_validator.d.ts.map +1 -1
  35. package/dest/msg_validators/attestation_validator/attestation_validator.js +3 -24
  36. package/dest/msg_validators/epoch_cache_lookup.d.ts +18 -0
  37. package/dest/msg_validators/epoch_cache_lookup.d.ts.map +1 -0
  38. package/dest/msg_validators/epoch_cache_lookup.js +29 -0
  39. package/dest/msg_validators/proposal_validator/proposal_validator.d.ts +1 -1
  40. package/dest/msg_validators/proposal_validator/proposal_validator.d.ts.map +1 -1
  41. package/dest/msg_validators/proposal_validator/proposal_validator.js +80 -82
  42. package/dest/msg_validators/tx_validator/data_validator.d.ts +1 -1
  43. package/dest/msg_validators/tx_validator/data_validator.d.ts.map +1 -1
  44. package/dest/msg_validators/tx_validator/data_validator.js +4 -3
  45. package/dest/msg_validators/tx_validator/factory.d.ts +40 -11
  46. package/dest/msg_validators/tx_validator/factory.d.ts.map +1 -1
  47. package/dest/msg_validators/tx_validator/factory.js +36 -5
  48. package/dest/msg_validators/tx_validator/gas_validator.d.ts +18 -10
  49. package/dest/msg_validators/tx_validator/gas_validator.d.ts.map +1 -1
  50. package/dest/msg_validators/tx_validator/gas_validator.js +37 -27
  51. package/dest/services/discv5/discV5_service.d.ts +2 -2
  52. package/dest/services/discv5/discV5_service.d.ts.map +1 -1
  53. package/dest/services/discv5/discV5_service.js +3 -1
  54. package/dest/services/libp2p/libp2p_service.d.ts +18 -9
  55. package/dest/services/libp2p/libp2p_service.d.ts.map +1 -1
  56. package/dest/services/libp2p/libp2p_service.js +127 -29
  57. package/dest/services/peer-manager/peer_manager.d.ts +1 -1
  58. package/dest/services/peer-manager/peer_manager.d.ts.map +1 -1
  59. package/dest/services/peer-manager/peer_manager.js +14 -0
  60. package/dest/services/tx_provider.d.ts +1 -1
  61. package/dest/services/tx_provider.d.ts.map +1 -1
  62. package/dest/services/tx_provider.js +3 -0
  63. package/dest/test-helpers/make-test-p2p-clients.d.ts +1 -1
  64. package/dest/test-helpers/make-test-p2p-clients.d.ts.map +1 -1
  65. package/dest/test-helpers/make-test-p2p-clients.js +3 -1
  66. package/dest/test-helpers/mock-pubsub.js +1 -1
  67. package/dest/test-helpers/reqresp-nodes.d.ts +1 -1
  68. package/dest/test-helpers/reqresp-nodes.d.ts.map +1 -1
  69. package/dest/test-helpers/reqresp-nodes.js +3 -1
  70. package/dest/test-helpers/testbench-utils.d.ts +4 -2
  71. package/dest/test-helpers/testbench-utils.d.ts.map +1 -1
  72. package/dest/test-helpers/testbench-utils.js +6 -0
  73. package/dest/testbench/p2p_client_testbench_worker.js +6 -2
  74. package/package.json +14 -14
  75. package/src/bootstrap/bootstrap.ts +3 -2
  76. package/src/client/factory.ts +7 -7
  77. package/src/client/interface.ts +1 -1
  78. package/src/client/p2p_client.ts +1 -1
  79. package/src/config.ts +11 -0
  80. package/src/mem_pools/attestation_pool/attestation_pool.ts +25 -0
  81. package/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts +31 -0
  82. package/src/mem_pools/tx_pool_v2/README.md +1 -1
  83. package/src/mem_pools/tx_pool_v2/eviction/eviction_manager.ts +13 -1
  84. package/src/mem_pools/tx_pool_v2/eviction/insufficient_fee_per_gas_eviction_rule.ts +17 -9
  85. package/src/mem_pools/tx_pool_v2/eviction/interfaces.ts +6 -1
  86. package/src/mem_pools/tx_pool_v2/interfaces.ts +10 -4
  87. package/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +95 -40
  88. package/src/msg_validators/attestation_validator/attestation_validator.ts +3 -23
  89. package/src/msg_validators/epoch_cache_lookup.ts +33 -0
  90. package/src/msg_validators/proposal_validator/README.md +3 -3
  91. package/src/msg_validators/proposal_validator/proposal_validator.ts +78 -77
  92. package/src/msg_validators/tx_validator/README.md +12 -8
  93. package/src/msg_validators/tx_validator/data_validator.ts +4 -3
  94. package/src/msg_validators/tx_validator/factory.ts +60 -14
  95. package/src/msg_validators/tx_validator/gas_validator.ts +46 -34
  96. package/src/services/discv5/discV5_service.ts +2 -1
  97. package/src/services/libp2p/libp2p_service.ts +138 -34
  98. package/src/services/peer-manager/peer_manager.ts +14 -0
  99. package/src/services/tx_provider.ts +3 -0
  100. package/src/test-helpers/make-test-p2p-clients.ts +5 -1
  101. package/src/test-helpers/mock-pubsub.ts +3 -3
  102. package/src/test-helpers/reqresp-nodes.ts +5 -1
  103. package/src/test-helpers/testbench-utils.ts +13 -1
  104. package/src/testbench/p2p_client_testbench_worker.ts +10 -2
@@ -1,7 +1,7 @@
1
1
  import type { SlotNumber } from '@aztec-labs/foundation/branded-types';
2
2
  import type { TypedEventEmitter } from '@aztec-labs/foundation/types';
3
3
  import type { L2Block, L2BlockId, L2BlockSource } from '@aztec-labs/stdlib/block';
4
- import type { BlockMinFeesProvider } from '@aztec-labs/stdlib/gas';
4
+ import type { NextBlockMinFeesProvider } from '@aztec-labs/stdlib/gas';
5
5
  import type { WorldStateSynchronizer } from '@aztec-labs/stdlib/interfaces/server';
6
6
  import type { BlockHeader, Tx, TxHash, TxValidator } from '@aztec-labs/stdlib/tx';
7
7
 
@@ -81,8 +81,11 @@ export type TxPoolV2Dependencies = {
81
81
  createTxValidator: () => Promise<TxValidator<TxMetaData>>;
82
82
  /** Checks whether a tx's setup-phase calls are on the allow list. Precomputed at receipt time. */
83
83
  checkAllowedSetupCalls: (tx: Tx) => Promise<boolean>;
84
- /** Provides projected minimum fees for the next block. Used by eviction rules instead of stale block header fees. */
85
- blockMinFeesProvider: BlockMinFeesProvider;
84
+ /**
85
+ * Provides the fee the next block will charge. Used by eviction rules instead of stale block header fees. Only the
86
+ * exact fee: admission pricing is applied by the validator `createTxValidator` builds.
87
+ */
88
+ nextBlockMinFeesProvider: NextBlockMinFeesProvider;
86
89
  };
87
90
 
88
91
  /**
@@ -163,6 +166,7 @@ export interface TxPoolV2 extends TypedEventEmitter<TxPoolV2Events> {
163
166
 
164
167
  /**
165
168
  * Handles a mined block - marks transactions as mined and evicts conflicting pending txs.
169
+ * Soft-deleted transactions in the block are re-added as mined from their retained data.
166
170
  * Uses nullifiers directly from the block to evict pending transactions with conflicts.
167
171
  * @param block - The complete mined block
168
172
  */
@@ -197,7 +201,9 @@ export interface TxPoolV2 extends TypedEventEmitter<TxPoolV2Events> {
197
201
 
198
202
  /**
199
203
  * Handles failed transaction execution.
200
- * Deletes transactions that failed during block building.
204
+ * Deletes transactions that failed during block building. Transactions already mined are left in place, since a
205
+ * block containing them may have been synced while they were being executed, and they must be kept until that
206
+ * block is finalized.
201
207
  * @param txHashes - Hashes of transactions that failed
202
208
  */
203
209
  handleFailedExecution(txHashes: TxHash[]): Promise<void>;
@@ -139,7 +139,7 @@ export class TxPoolV2Impl {
139
139
 
140
140
  // Post-event eviction rules (run after events to check ALL pending txs)
141
141
  this.#evictionManager.registerRule(new InvalidTxsAfterMiningRule());
142
- this.#evictionManager.registerRule(new InsufficientFeePerGasEvictionRule(deps.blockMinFeesProvider));
142
+ this.#evictionManager.registerRule(new InsufficientFeePerGasEvictionRule(deps.nextBlockMinFeesProvider));
143
143
  this.#evictionManager.registerRule(new InvalidTxsAfterReorgRule(deps.worldStateSynchronizer));
144
144
  this.#evictionManager.registerRule(new FeePayerBalanceEvictionRule(deps.worldStateSynchronizer));
145
145
  // LowPriorityEvictionRule handles cases where txs become pending via prepareForSlot (unprotect)
@@ -466,27 +466,21 @@ export class TxPoolV2Impl {
466
466
  const missing: TxHash[] = [];
467
467
  let softDeletedHits = 0;
468
468
  let missingPreviouslyEvicted = 0;
469
+ // A soft-deleted tx is never in the indices, so every tx loaded here is resurrected below.
470
+ const softDeleted = await this.#loadSoftDeletedTxs(txHashes);
469
471
 
470
472
  await this.#store.transactionAsync(async () => {
471
473
  for (const txHash of txHashes) {
472
474
  const txHashStr = txHash.toString();
475
+ const resurrectable = softDeleted.get(txHashStr);
473
476
 
474
477
  if (this.#indices.has(txHashStr)) {
475
478
  // Update protection for existing tx
476
479
  this.#indices.updateProtection(txHashStr, slotNumber);
477
- } else if (this.#deletedPool.isSoftDeleted(txHashStr)) {
478
- // Resurrect soft-deleted tx as protected. Load the proof too: #addTx rewrites both entries, so re-adding
479
- // a proofless tx would wipe the stored proof.
480
- const buffer = await this.#txsDB.getAsync(txHashStr);
481
- if (buffer) {
482
- const tx = await this.#loadTxWithProof(txHashStr, buffer);
483
- await this.#addTx(tx, { protected: slotNumber });
484
- softDeletedHits++;
485
- } else {
486
- // Data missing despite soft-delete flag — treat as truly missing
487
- this.#indices.setProtection(txHashStr, slotNumber);
488
- missing.push(txHash);
489
- }
480
+ } else if (resurrectable) {
481
+ // Resurrect soft-deleted tx as protected
482
+ await this.#addTx(resurrectable.tx, { protected: slotNumber }, {}, resurrectable.meta);
483
+ softDeletedHits++;
490
484
  } else {
491
485
  // Truly missing — pre-record protection for tx we don't have yet
492
486
  this.#indices.setProtection(txHashStr, slotNumber);
@@ -546,41 +540,56 @@ export class TxPoolV2Impl {
546
540
  const txHashes = block.body.txEffects.map(tx => tx.txHash);
547
541
  const nullifiers = block.body.txEffects.flatMap(tx => tx.nullifiers.map(n => n.toString()));
548
542
 
549
- // Step 3: Collect fee payers from txs we have in the pool (for balance-based eviction)
550
- const feePayers: string[] = [];
543
+ // A soft-deleted tx is never in the indices, so every tx loaded here is resurrected below.
544
+ const softDeleted = await this.#loadSoftDeletedTxs(txHashes);
551
545
  const found: TxMetaData[] = [];
552
- for (const txHash of txHashes) {
553
- const meta = this.#indices.getMetadata(txHash.toString());
554
- if (meta) {
555
- feePayers.push(meta.feePayer);
556
- found.push(meta);
557
- }
558
- }
546
+ const resurrected: TxMetaData[] = [];
559
547
 
560
548
  await this.#store.transactionAsync(async () => {
561
- // Step 4: Mark txs as mined (only those we have in the pool)
562
- for (const meta of found) {
563
- this.#indices.markAsMined(meta, blockId);
564
- await this.#deletedPool.clearIfMinedHigher(meta.txHash, blockId.number);
549
+ // Step 3: Mark txs as mined, resurrecting soft-deleted ones. A mined tx must be kept until its block is
550
+ // finalized, whereas a slot-soft-deleted one is hard-deleted on the next slot.
551
+ for (const txHash of txHashes) {
552
+ const txHashStr = txHash.toString();
553
+ const meta = this.#indices.getMetadata(txHashStr);
554
+ const resurrectable = softDeleted.get(txHashStr);
555
+ if (meta) {
556
+ this.#indices.markAsMined(meta, blockId);
557
+ found.push(meta);
558
+ } else if (resurrectable) {
559
+ resurrected.push(await this.#addTx(resurrectable.tx, { mined: blockId }, {}, resurrectable.meta));
560
+ } else {
561
+ continue;
562
+ }
563
+ await this.#deletedPool.clearIfMinedHigher(txHashStr, blockId.number);
565
564
  }
566
565
 
567
- // Step 5: Run post-event eviction rules (inside transaction for atomicity)
568
- await this.#evictionManager.evictAfterNewBlock(block.header, nullifiers, feePayers);
566
+ // Step 4: Run post-event eviction rules (inside transaction for atomicity)
567
+ const feePayers = new Set([...found, ...resurrected].map(meta => meta.feePayer));
568
+ await this.#evictionManager.evictAfterNewBlock(block.header, nullifiers, [...feePayers]);
569
569
  });
570
570
 
571
- if (found.length > 0) {
571
+ if (resurrected.length > 0) {
572
+ this.#instrumentation.recordSoftDeletedHits(resurrected.length);
573
+ }
574
+
575
+ if (found.length > 0 || resurrected.length > 0) {
572
576
  // receivedAt is 0 for txs hydrated from the DB on restart (true receive time lost) — leave
573
- // their delay undefined so the metric isn't polluted by epoch-sized values.
577
+ // their delay undefined so the metric isn't polluted by epoch-sized values. A resurrected tx
578
+ // lost its receive time along with its metadata when it was deleted.
574
579
  const now = this.#dateProvider.now();
575
- this.#callbacks.onTxsMined(
576
- found.map(m => ({
580
+ this.#callbacks.onTxsMined([
581
+ ...found.map(m => ({
577
582
  txHash: m.txHash,
578
583
  minedDelayMs: m.receivedAt > 0 ? now - m.receivedAt : undefined,
579
584
  })),
580
- );
585
+ ...resurrected.map(m => ({ txHash: m.txHash, minedDelayMs: undefined })),
586
+ ]);
581
587
  }
582
588
 
583
- this.#log.info(`Marked ${found.length} txs as mined in block ${blockId.number}`);
589
+ this.#log.info(`Marked ${found.length + resurrected.length} txs as mined in block ${blockId.number}`, {
590
+ blockNumber: blockId.number,
591
+ resurrectedTxHashes: resurrected.map(m => m.txHash),
592
+ });
584
593
  }
585
594
 
586
595
  async prepareForSlot(slotNumber: SlotNumber): Promise<void> {
@@ -598,11 +607,14 @@ export class TxPoolV2Impl {
598
607
  const txsToRestore = this.#indices.filterRestorable(expiredProtected);
599
608
  if (txsToRestore.length === 0) {
600
609
  this.#log.debug(`Preparing for slot ${slotNumber}, no txs to unprotect`);
601
- return;
610
+ } else {
611
+ this.#log.info(`Preparing for slot ${slotNumber}: unprotecting ${txsToRestore.length} txs`);
612
+ await this.#restoreUnprotectedToPending(txsToRestore, 'during prepareForSlot');
602
613
  }
603
614
 
604
- this.#log.info(`Preparing for slot ${slotNumber}: unprotecting ${txsToRestore.length} txs`);
605
- await this.#restoreUnprotectedToPending(txsToRestore, 'during prepareForSlot');
615
+ // Step 4: Sweep pending txs that can no longer pay the next block's fee. The fee is stable
616
+ // across the slot, so this runs here instead of after every mined block.
617
+ await this.#evictionManager.evictAfterSlotPrepared(slotNumber);
606
618
  });
607
619
  }
608
620
 
@@ -719,11 +731,24 @@ export class TxPoolV2Impl {
719
731
  }
720
732
 
721
733
  async handleFailedExecution(txHashes: TxHash[]): Promise<void> {
734
+ // A tx can be mined by a block that arrives while the sequencer is still executing it. A mined tx must be kept
735
+ // until its block is finalized, whereas deleting it could slot-soft-delete it and lose it on the next slot.
736
+ const toDelete: string[] = [];
737
+ const skippedMined: string[] = [];
738
+ for (const txHash of txHashes) {
739
+ const txHashStr = txHash.toString();
740
+ const isMined = this.#indices.getMetadata(txHashStr)?.minedL2BlockId !== undefined;
741
+ (isMined ? skippedMined : toDelete).push(txHashStr);
742
+ }
743
+
722
744
  await this.#store.transactionAsync(async () => {
723
- await this.#deleteTxsBatch(txHashes.map(h => h.toString()));
745
+ await this.#deleteTxsBatch(toDelete);
724
746
  });
725
747
 
726
- this.#log.info(`Deleted ${txHashes.length} failed txs`, { txHashes: txHashes.map(h => h.toString()) });
748
+ this.#log.info(`Deleted ${toDelete.length} failed txs, skipped ${skippedMined.length} already mined`, {
749
+ txHashes: toDelete,
750
+ skippedMinedTxHashes: skippedMined,
751
+ });
727
752
  }
728
753
 
729
754
  /**
@@ -1021,6 +1046,36 @@ export class TxPoolV2Impl {
1021
1046
  return meta;
1022
1047
  }
1023
1048
 
1049
+ /**
1050
+ * Loads the retained data of those given txs that are soft-deleted and rebuilds their metadata, so they can be
1051
+ * re-added to the pool. Call it before the store transaction: it does all the throwable I/O, and leaves out a tx
1052
+ * whose data is gone or unreadable, as if it were missing.
1053
+ */
1054
+ async #loadSoftDeletedTxs(txHashes: TxHash[]): Promise<Map<string, { tx: Tx; meta: TxMetaData }>> {
1055
+ const loaded = new Map<string, { tx: Tx; meta: TxMetaData }>();
1056
+ // Loaded sequentially: usually none of the given txs are soft-deleted, and each one that is costs only local reads.
1057
+ for (const txHash of txHashes) {
1058
+ const txHashStr = txHash.toString();
1059
+ if (!this.#deletedPool.isSoftDeleted(txHashStr)) {
1060
+ continue;
1061
+ }
1062
+ try {
1063
+ const buffer = await this.#txsDB.getAsync(txHashStr);
1064
+ if (!buffer) {
1065
+ continue;
1066
+ }
1067
+ // Load the proof too: #addTx rewrites both entries, so re-adding a proofless tx would wipe the stored proof.
1068
+ const tx = await this.#loadTxWithProof(txHashStr, buffer);
1069
+ // The metadata was dropped on deletion, so recompute the setup-call flag instead of defaulting it to allowed.
1070
+ const meta = await buildTxMetaData(tx, await this.#checkAllowedSetupCalls(tx));
1071
+ loaded.set(txHashStr, { tx, meta });
1072
+ } catch (err) {
1073
+ this.#log.warn(`Failed to load soft-deleted tx ${txHashStr}`, { txHash: txHashStr, err });
1074
+ }
1075
+ }
1076
+ return loaded;
1077
+ }
1078
+
1024
1079
  /**
1025
1080
  * Deletes a transaction from both indices and DB.
1026
1081
  * Emits onTxsRemoved callback immediately after DB delete.
@@ -1,5 +1,4 @@
1
1
  import type { EpochCacheInterface } from '@aztec-labs/epoch-cache';
2
- import { NoCommitteeError } from '@aztec-labs/ethereum/contracts';
3
2
  import { type Logger, createLogger } from '@aztec-labs/foundation/log';
4
3
  import {
5
4
  type CheckpointAttestation,
@@ -11,6 +10,7 @@ import {
11
10
  } from '@aztec-labs/stdlib/p2p';
12
11
  import type { ConsensusTimetable } from '@aztec-labs/stdlib/timetable';
13
12
 
13
+ import { mapEpochCacheLookupFailure } from '../epoch_cache_lookup.js';
14
14
  import { classifyReceiveWindowArrival } from '../receive_window.js';
15
15
 
16
16
  export class CheckpointAttestationValidator implements P2PValidator<CheckpointAttestation> {
@@ -38,26 +38,6 @@ export class CheckpointAttestationValidator implements P2PValidator<CheckpointAt
38
38
  async validate(message: CheckpointAttestation): Promise<ValidationResult> {
39
39
  const slotNumber = message.payload.header.slotNumber;
40
40
 
41
- // Only the epochCache committee/proposer lookups can fail on receiver-local causes (L1 RPC down,
42
- // or this node behind), and that says nothing about the relaying peer. Map such a failure to
43
- // ignore rather than letting it throw, since a thrown validation defaults to reject + penalize
44
- // and would punish an honest relayer for our own failure. A missing committee is still the peer's
45
- // fault (they shouldn't be sending it), so it stays a reject. This is scoped to just the lookups:
46
- // any other error in validation still throws and surfaces.
47
- const onLookupFailure = (e: unknown): ValidationResult => {
48
- if (e instanceof NoCommitteeError) {
49
- this.logger.warn(`No committee exists for checkpoint attestation for slot ${slotNumber}`);
50
- return { result: 'reject', severity: PeerErrorSeverity.LowToleranceError };
51
- }
52
- this.logger.warn(
53
- `Ignoring checkpoint attestation for slot ${slotNumber} after a local committee lookup failure`,
54
- {
55
- error: e instanceof Error ? e.message : String(e),
56
- },
57
- );
58
- return { result: 'ignore' };
59
- };
60
-
61
41
  // Cross-chain replay check: reject attestations that carry a foreign signing domain.
62
42
  if (!hasValidSignatureContext(message.payload, this.signatureContext)) {
63
43
  this.logger.warn(`Rejecting checkpoint attestation with foreign signature context for slot ${slotNumber}`, {
@@ -108,7 +88,7 @@ export class CheckpointAttestationValidator implements P2PValidator<CheckpointAt
108
88
  try {
109
89
  inCommittee = await this.epochCache.isInCommittee(slotNumber, attester);
110
90
  } catch (e) {
111
- return onLookupFailure(e);
91
+ return mapEpochCacheLookupFailure(e, this.logger, 'checkpoint attestation', slotNumber);
112
92
  }
113
93
  if (!inCommittee) {
114
94
  this.logger.warn(`Attester ${attester.toString()} is not in committee for slot ${slotNumber}`);
@@ -123,7 +103,7 @@ export class CheckpointAttestationValidator implements P2PValidator<CheckpointAt
123
103
  try {
124
104
  expectedProposer = await this.epochCache.getProposerAttesterAddressInSlot(slotNumber);
125
105
  } catch (e) {
126
- return onLookupFailure(e);
106
+ return mapEpochCacheLookupFailure(e, this.logger, 'checkpoint attestation', slotNumber);
127
107
  }
128
108
  if (!expectedProposer) {
129
109
  this.logger.warn(`No proposer defined for slot ${slotNumber}`);
@@ -0,0 +1,33 @@
1
+ import { NoCommitteeError } from '@aztec-labs/ethereum/contracts';
2
+ import type { SlotNumber } from '@aztec-labs/foundation/branded-types';
3
+ import type { Logger } from '@aztec-labs/foundation/log';
4
+ import { PeerErrorSeverity, type ValidationResult } from '@aztec-labs/stdlib/p2p';
5
+
6
+ /**
7
+ * Maps a failure of an epoch-cache lookup during p2p validation to a verdict, shared by the proposal
8
+ * and attestation validators so the "a local epoch-cache failure is never the relaying peer's fault"
9
+ * rule lives in one place.
10
+ *
11
+ * A missing committee (`NoCommitteeError`) is the peer's fault — they should not be sending us a
12
+ * proposal/attestation for a slot with no committee — so it rejects. Any other failure is
13
+ * receiver-local (an L1 RPC outage, or this node being behind) and says nothing about the relaying
14
+ * peer, so it ignores rather than penalizes. Only the local failure is warned; a missing committee is
15
+ * a routine peer-fault reject and needs no log line.
16
+ *
17
+ * `subject` names the gossip object for the log ("proposal" / "checkpoint attestation").
18
+ */
19
+ export function mapEpochCacheLookupFailure(
20
+ e: unknown,
21
+ logger: Logger,
22
+ subject: string,
23
+ slotNumber: SlotNumber,
24
+ ): ValidationResult {
25
+ if (e instanceof NoCommitteeError) {
26
+ return { result: 'reject', severity: PeerErrorSeverity.LowToleranceError };
27
+ }
28
+ logger.warn(`Ignoring ${subject} for slot ${slotNumber} after a local epoch-cache lookup failure`, {
29
+ slotNumber,
30
+ error: e instanceof Error ? e.message : String(e),
31
+ });
32
+ return { result: 'ignore' };
33
+ }
@@ -28,7 +28,7 @@ Deserialization guards: `BlockProposal.fromBuffer` and `SignedTxs.fromBuffer` bo
28
28
  | # | Rule | Consequence |
29
29
  |---|------|-------------|
30
30
  | 9 | **Duplicate**: same archive root already stored | IGNORE (no penalty) |
31
- | 10 | **Per-position cap**: max 2 proposals per (slot, indexWithinCheckpoint) | REJECT + HighToleranceError |
31
+ | 10 | **Per-position cap**: max 2 proposals per (slot, indexWithinCheckpoint) | IGNORE (receiver-local drop: no penalty, no re-broadcast) |
32
32
  | 11 | **Equivocation**: >1 distinct proposal for same (slot, index) | ACCEPT (rebroadcast for detection). At count=2: `duplicateProposalCallback` fires -> slash event (`OffenseType.DUPLICATE_PROPOSAL`, configured via `slashDuplicateProposalPenalty`) |
33
33
 
34
34
  ### Stage 3: Validator-Client Processing (BlockProposalHandler)
@@ -84,7 +84,7 @@ The checkpoint's embedded `lastBlock` is extracted via `getBlockProposal()` and
84
84
  | Rule | Consequence | File |
85
85
  |------|-------------|------|
86
86
  | Block proposal must pass `BlockProposalValidator.validate()` | If REJECT: entire checkpoint REJECTED | `libp2p_service.ts` |
87
- | Block proposal must not exceed per-position cap (2) | Checkpoint REJECTED + HighToleranceError | same |
87
+ | Block proposal must not exceed per-position cap (2) | Checkpoint IGNORED (terminal block dropped as a receiver-local drop: no store, no re-broadcast, no penalty) | same |
88
88
  | Block equivocation detected (>1 proposals for same slot+index) | Checkpoint REJECTED (block itself is ACCEPT for re-broadcast) | same |
89
89
 
90
90
  ### Stage 3: Mempool (Attestation Pool)
@@ -92,7 +92,7 @@ The checkpoint's embedded `lastBlock` is extracted via `getBlockProposal()` and
92
92
  | Rule | Consequence | File |
93
93
  |------|-------------|------|
94
94
  | Duplicate (same archive ID) | IGNORE (no penalty). Embedded block still processed if valid. | `attestation_pool.ts` |
95
- | Per-slot cap: `MAX_CHECKPOINT_PROPOSALS_PER_SLOT` = 2 | REJECT + HighToleranceError. Embedded block still processed. | same |
95
+ | Per-slot cap: `MAX_CHECKPOINT_PROPOSALS_PER_SLOT` = 2 | IGNORE (receiver-local drop: no penalty, no re-broadcast). Embedded block still processed. | same |
96
96
 
97
97
  ### Stage 4: Equivocation Detection
98
98
 
@@ -1,5 +1,4 @@
1
1
  import type { EpochCacheInterface } from '@aztec-labs/epoch-cache';
2
- import { NoCommitteeError } from '@aztec-labs/ethereum/contracts';
3
2
  import { type Logger, createLogger } from '@aztec-labs/foundation/log';
4
3
  import { MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT, MAX_TXS_PER_CHECKPOINT } from '@aztec-labs/stdlib/deserialization';
5
4
  import {
@@ -12,6 +11,7 @@ import {
12
11
  } from '@aztec-labs/stdlib/p2p';
13
12
  import type { ConsensusTimetable } from '@aztec-labs/stdlib/timetable';
14
13
 
14
+ import { mapEpochCacheLookupFailure } from '../epoch_cache_lookup.js';
15
15
  import { classifyReceiveWindowArrival } from '../receive_window.js';
16
16
 
17
17
  /** Validates header-level and tx-level fields of block and checkpoint proposals. */
@@ -50,91 +50,92 @@ export class ProposalValidator {
50
50
 
51
51
  /** Validates header-level fields: slot, signature, and proposer. */
52
52
  public async validate(proposal: BlockProposal | CheckpointProposalCore): Promise<ValidationResult> {
53
- try {
54
- // Cross-chain replay check: reject proposals that carry a foreign signing domain.
55
- if (!hasValidSignatureContext(proposal, this.signatureContext)) {
56
- this.logger.warn(`Penalizing peer for proposal with foreign signature context`, {
57
- chainId: proposal.signatureContext.chainId,
58
- rollupAddress: proposal.signatureContext.rollupAddress.toString(),
59
- expectedChainId: this.signatureContext.chainId,
60
- expectedRollupAddress: this.signatureContext.rollupAddress.toString(),
61
- });
62
- return { result: 'reject', severity: PeerErrorSeverity.LowToleranceError };
63
- }
53
+ // Cross-chain replay check: reject proposals that carry a foreign signing domain.
54
+ if (!hasValidSignatureContext(proposal, this.signatureContext)) {
55
+ this.logger.warn(`Penalizing peer for proposal with foreign signature context`, {
56
+ chainId: proposal.signatureContext.chainId,
57
+ rollupAddress: proposal.signatureContext.rollupAddress.toString(),
58
+ expectedChainId: this.signatureContext.chainId,
59
+ expectedRollupAddress: this.signatureContext.rollupAddress.toString(),
60
+ });
61
+ return { result: 'reject', severity: PeerErrorSeverity.LowToleranceError };
62
+ }
64
63
 
65
- // Slot check: the tight checkpoint proposal receive window (`[receiveStart - δ, target_slot_start -
66
- // E - D + δ]`) is the sole acceptance gate, applied to both block and checkpoint proposals. The
67
- // window itself bounds which slots are valid, so far/wrong slots fall outside it. Every block
68
- // proposal for slot N is sent before the checkpoint proposal for slot N, so nothing legitimate can
69
- // arrive after the checkpoint receive deadline; gating block proposals on the same window rejects
70
- // late block proposals at p2p ingress. The attestation deadline remains their re-execution/
71
- // validation deadline downstream, not their arrival gate.
72
- const slotNumber = proposal.slotNumber;
73
- if (!this.skipSlotValidation) {
74
- // Proposal receive window: [checkpoint_proposal_receive_start, checkpoint_proposal_receive_deadline],
75
- // widened by the configured clock-disparity tolerance on both ends.
76
- const startSeconds = this.timetable.getCheckpointProposalReceiveStart(slotNumber);
77
- const deadlineSeconds = this.timetable.getCheckpointProposalReceiveDeadline(slotNumber);
78
- const nowMs = Number(this.epochCache.getEpochAndSlotNow().nowMs);
79
- const windowMiss = classifyReceiveWindowArrival(
80
- nowMs,
81
- startSeconds * 1000 - this.clockDisparityMs,
82
- deadlineSeconds * 1000 + this.clockDisparityMs,
64
+ // Slot check: the tight checkpoint proposal receive window (`[receiveStart - δ, target_slot_start -
65
+ // E - D + δ]`) is the sole acceptance gate, applied to both block and checkpoint proposals. The
66
+ // window itself bounds which slots are valid, so far/wrong slots fall outside it. Every block
67
+ // proposal for slot N is sent before the checkpoint proposal for slot N, so nothing legitimate can
68
+ // arrive after the checkpoint receive deadline; gating block proposals on the same window rejects
69
+ // late block proposals at p2p ingress. The attestation deadline remains their re-execution/
70
+ // validation deadline downstream, not their arrival gate.
71
+ const slotNumber = proposal.slotNumber;
72
+ if (!this.skipSlotValidation) {
73
+ // Proposal receive window: [checkpoint_proposal_receive_start, checkpoint_proposal_receive_deadline],
74
+ // widened by the configured clock-disparity tolerance on both ends.
75
+ const startSeconds = this.timetable.getCheckpointProposalReceiveStart(slotNumber);
76
+ const deadlineSeconds = this.timetable.getCheckpointProposalReceiveDeadline(slotNumber);
77
+ const nowMs = Number(this.epochCache.getEpochAndSlotNow().nowMs);
78
+ const windowMiss = classifyReceiveWindowArrival(
79
+ nowMs,
80
+ startSeconds * 1000 - this.clockDisparityMs,
81
+ deadlineSeconds * 1000 + this.clockDisparityMs,
82
+ );
83
+ if (windowMiss) {
84
+ this.logger.warn(
85
+ `Proposal for slot ${slotNumber} is outside its receive window (${windowMiss.outcome.result})`,
86
+ {
87
+ slotNumber,
88
+ nowMs,
89
+ missMs: windowMiss.missMs,
90
+ windowStartSeconds: startSeconds,
91
+ windowDeadlineSeconds: deadlineSeconds,
92
+ },
83
93
  );
84
- if (windowMiss) {
85
- this.logger.warn(
86
- `Proposal for slot ${slotNumber} is outside its receive window (${windowMiss.outcome.result})`,
87
- {
88
- slotNumber,
89
- nowMs,
90
- missMs: windowMiss.missMs,
91
- windowStartSeconds: startSeconds,
92
- windowDeadlineSeconds: deadlineSeconds,
93
- },
94
- );
95
- return windowMiss.outcome;
96
- }
97
- }
98
-
99
- // Signature validity
100
- const proposer = proposal.getSender();
101
- if (!proposer) {
102
- this.logger.warn(`Penalizing peer for proposal with invalid signature`);
103
- return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError };
94
+ return windowMiss.outcome;
104
95
  }
96
+ }
105
97
 
106
- // An undefined proposer means an empty committee (anyone may propose), so skip the
107
- // proposer-equality check and keep validating. A missing committee instead throws
108
- // NoCommitteeError, handled as a reject below.
109
- const expectedProposer = await this.epochCache.getProposerAttesterAddressInSlot(slotNumber);
110
- if (expectedProposer !== undefined && !proposer.equals(expectedProposer)) {
111
- this.logger.warn(`Penalizing peer for invalid proposer for current slot ${slotNumber}`, {
112
- expectedProposer,
113
- proposer: proposer.toString(),
114
- });
115
- return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError };
116
- }
98
+ // Signature validity
99
+ const proposer = proposal.getSender();
100
+ if (!proposer) {
101
+ this.logger.warn(`Penalizing peer for proposal with invalid signature`);
102
+ return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError };
103
+ }
117
104
 
118
- // A block proposal whose index lands at or beyond the hard attestable ceiling is structurally
119
- // impossible garbage, so reject it immediately at ingress. Indices in
120
- // `[maxBlocksPerCheckpoint, MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT)` are over the consensus limit
121
- // but structurally valid proposer misbehavior; they pass gossip validation here so the offending
122
- // proposal can be retained and re-broadcast as slashing evidence (handled downstream in the p2p
123
- // service), rather than penalizing the relaying peer.
124
- if ('indexWithinCheckpoint' in proposal) {
125
- const indexResult = this.validateBlockIndexWithinCheckpoint(proposal);
126
- if (indexResult.result !== 'accept') {
127
- return indexResult;
128
- }
105
+ // A block proposal whose index lands at or beyond the hard attestable ceiling is structurally
106
+ // impossible garbage, so reject it immediately at ingress. This is peer-attributable and needs no
107
+ // epoch-cache lookup, so it runs before the proposer lookup: a structurally-invalid index must be
108
+ // rejected even when a local lookup fails. Indices in
109
+ // `[maxBlocksPerCheckpoint, MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT)` are over the consensus limit
110
+ // but structurally valid proposer misbehavior; they pass gossip validation here so the offending
111
+ // proposal can be retained and re-broadcast as slashing evidence (handled downstream in the p2p
112
+ // service), rather than penalizing the relaying peer.
113
+ if ('indexWithinCheckpoint' in proposal) {
114
+ const indexResult = this.validateBlockIndexWithinCheckpoint(proposal);
115
+ if (indexResult.result !== 'accept') {
116
+ return indexResult;
129
117
  }
118
+ }
130
119
 
131
- return { result: 'accept' };
120
+ // An undefined proposer means an empty committee (anyone may propose), so skip the
121
+ // proposer-equality check and keep validating. Only this lookup can fail on a receiver-local
122
+ // cause (L1 RPC down, or this node behind); mapEpochCacheLookupFailure ignores that (it is not the
123
+ // relaying peer's fault) and rejects a missing committee (which is).
124
+ let expectedProposer;
125
+ try {
126
+ expectedProposer = await this.epochCache.getProposerAttesterAddressInSlot(slotNumber);
132
127
  } catch (e) {
133
- if (e instanceof NoCommitteeError) {
134
- return { result: 'reject', severity: PeerErrorSeverity.LowToleranceError };
135
- }
136
- throw e;
128
+ return mapEpochCacheLookupFailure(e, this.logger, 'proposal', slotNumber);
137
129
  }
130
+ if (expectedProposer !== undefined && !proposer.equals(expectedProposer)) {
131
+ this.logger.warn(`Penalizing peer for invalid proposer for current slot ${slotNumber}`, {
132
+ expectedProposer,
133
+ proposer: proposer.toString(),
134
+ });
135
+ return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError };
136
+ }
137
+
138
+ return { result: 'accept' };
138
139
  }
139
140
 
140
141
  /**
@@ -22,12 +22,14 @@ Unsolicited transactions from any peer. Fully validated in two stages with a poo
22
22
 
23
23
  | Step | What runs | On failure |
24
24
  |------|-----------|------------|
25
- | **Stage 1** (fast) | TxPermitted, Data, Metadata, Timestamp, DoubleSpend, MinGasLimits, MaxGasLimits, Gas, Phases, BlockHeader | Penalize peer, reject tx |
25
+ | **Stage 1** (fast) | TxPermitted, Data, Metadata, Timestamp, DoubleSpend, MinGasLimits, MaxGasLimits, MaxFeePerGas, MaxFeePerGasFloor, FeePayerBalance, Phases, BlockHeader | Penalize peer, reject tx (except MaxFeePerGas: ignore tx, no penalty) |
26
26
  | **Pool pre-check** | `canAddPendingTx` — checks for duplicates, pool capacity | Ignore tx (no penalty) |
27
27
  | **Stage 2** (slow) | Proof verification | Penalize peer, reject tx |
28
28
  | **Pool add** | `addPendingTxs` | Accept, ignore, or reject |
29
29
 
30
- Each stage-1 and stage-2 validator is paired with a `PeerErrorSeverity`. If a validator fails, the sending peer is penalized with that severity. The `doubleSpendValidator` has special handling: its severity is determined by how recently the nullifier appeared (recent = high tolerance, old = low tolerance).
30
+ Each stage-1 and stage-2 validator is paired with a `PeerErrorSeverity`, and the sending peer is penalized with that severity when it fails. When several validators fail, the harshest consequence wins. The `doubleSpendValidator` has special handling: its severity is determined by how recently the nullifier appeared (recent = high tolerance, old = low tolerance).
31
+
32
+ `maxFeePerGasValidator` is the exception, marked `IgnoreWithoutPenalty`: a tx below this node's admission fee (the next-block fee, or the L1-forward fee while the next block cannot be priced) may be perfectly valid for a peer ahead of us, so it is dropped without propagating and the peer keeps its score. That excuse stops at a penalty floor, half the lower of the admission and L1-forward fees: `maxFeePerGasFloorValidator` checks it with a mid-tolerance penalty. The slack covers a peer that has seen a checkpoint we have not, or a fresher L1 block, while zero-fee and negligible-fee spam still costs the sender. Being the mildest consequence, the ignore never masks a penalized failure on the same tx — which is also why the fee-payer balance check is a separate entry (`feePayerBalanceValidator`) that stays penalized.
31
33
 
32
34
  ### 2. JSON-RPC
33
35
 
@@ -96,10 +98,11 @@ The `AllowedSetupCallsMetaValidator` checks a precomputed boolean flag (`TxMetaD
96
98
  | `MetadataTxValidator` | Chain ID, rollup version, protocol contracts hash, VK tree root | 4.18 us |
97
99
  | `TimestampTxValidator` | Transaction has not expired (expiration timestamp vs next slot) | 1.56 us |
98
100
  | `DoubleSpendTxValidator` | Nullifiers do not already exist in the nullifier tree | 106.08 us |
99
- | `GasTxValidator` | Max fee per gas meets current block fees (delegates to `MaxFeePerGasValidator`), and fee payer has sufficient FeeJuice balance | 1.02 ms |
101
+ | `GasTxValidator` | Max fee per gas meets current block fees (delegates to `MaxFeePerGasValidator`), and fee payer has sufficient FeeJuice balance (delegates to `FeePayerBalanceValidator`) | 1.02 ms |
102
+ | `FeePayerBalanceValidator` | Fee payer's FeeJuice balance, plus any setup-phase claim, covers the tx's fee limit. Used standalone in gossip, alongside `MaxFeePerGasValidator`; also called internally by `GasTxValidator` | ~1 ms |
100
103
  | `MinGasLimitsValidator` | Gas limits are >= the fixed protocol overheads. Applies on every entry point, with no exemptions | 3–10 us |
101
104
  | `MaxGasLimitsValidator` | Gas limits are <= AVM max processable L2 gas (optionally clamped further by network admission limits). Exempted on the gas estimation path | 3–10 us |
102
- | `MaxFeePerGasValidator` | Max fee per gas >= current block gas fees on both dimensions (DA and L2). Used standalone in pool migration; also called internally by `GasTxValidator` | 3–10 us |
105
+ | `MaxFeePerGasValidator` | Max fee per gas >= current block gas fees on both dimensions (DA and L2). Used standalone in pool migration and in gossip; also called internally by `GasTxValidator` | 3–10 us |
103
106
  | `PhasesTxValidator` | Public function calls in setup phase are on the allow list | 10.12–13.12 us |
104
107
  | `AllowedSetupCallsMetaValidator` | Checks the precomputed `allowedSetupCalls` flag on `TxMetaData`. Used in pool migration instead of the full `PhasesTxValidator` | — |
105
108
  | `BlockHeaderTxValidator` | Transaction's anchor block hash exists in the archive tree | 98.88 us |
@@ -115,16 +118,17 @@ The `AllowedSetupCallsMetaValidator` checks a precomputed boolean flag (`TxMetaD
115
118
  | Metadata | Stage 1 | Yes | Yes | — | — |
116
119
  | Timestamp | Stage 1 | Yes | — | Yes | Yes |
117
120
  | DoubleSpend | Stage 1 | Yes | — | Yes | Yes |
118
- | Gas (fee balance) | Stage 1 | Optional* | — | Yes | — |
121
+ | Gas (fee balance) | — | Optional* | — | Yes | — |
119
122
  | MinGasLimits | Stage 1 | Yes | — | Yes | Yes |
120
123
  | MaxGasLimits | Stage 1 | Yes*** | — | Yes | Yes |
121
- | MaxFeePerGas (standalone) | — | — | — | — | Yes |
124
+ | MaxFeePerGas (standalone) | Stage 1 | — | — | — | Yes |
125
+ | FeePayerBalance (standalone) | Stage 1 | — | — | — | — |
122
126
  | Phases | Stage 1 | Yes | — | Yes | — |
123
127
  | AllowedSetupCalls | — | — | — | — | Yes |
124
128
  | BlockHeader | Stage 1 | Yes | — | Yes | Yes |
125
129
  | Proof | Stage 2 | Optional** | Yes | — | — |
126
130
 
127
- \* Gas balance check is skipped when `skipFeeEnforcement` is set (testing/dev). `GasTxValidator` internally delegates to `MaxFeePerGasValidator` as its first step, so fee-per-gas is checked wherever `GasTxValidator` runs. Pool migration uses `MaxFeePerGasValidator` standalone because it doesn't need the balance check. Declared gas-limit validation is owned solely by `MinGasLimitsValidator` and `MaxGasLimitsValidator`.
131
+ \* Gas balance check is skipped when `skipFeeEnforcement` is set (testing/dev). `GasTxValidator` internally delegates to `MaxFeePerGasValidator` as its first step, so fee-per-gas is checked wherever `GasTxValidator` runs. Gossip runs both halves standalone instead, so each can carry its own peer penalty. Pool migration uses `MaxFeePerGasValidator` standalone because it doesn't need the balance check. Declared gas-limit validation is owned solely by `MinGasLimitsValidator` and `MaxGasLimitsValidator`.
128
132
  \** Proof verification is skipped for simulations (no verifier provided).
129
133
  \*** Only the ceiling is skipped for simulations: gas estimation submits limits above the per-tx maximum, and the wallet clamps the real tx to the admission limit afterward. The floor still applies, since a tx below it can never be mined.
130
134
 
@@ -132,6 +136,6 @@ The gas-limit bounds `MaxGasLimitsValidator` enforces here — the per-tx protoc
132
136
 
133
137
  ## Fee-Per-Gas Rejection Strategy
134
138
 
135
- The `MaxFeePerGasValidator` and `InsufficientFeePerGasEvictionRule` reject and evict transactions whose `maxFeesPerGas` falls below the current block's gas fees. This is a simple strategy: if a tx can't pay the current fees, it gets rejected on entry and evicted after each new block.
139
+ The `MaxFeePerGasValidator` and `InsufficientFeePerGasEvictionRule` reject and evict transactions whose `maxFeesPerGas` falls below the fee the next block will charge. Admission falls back to the L1-forward fee while the next block cannot be priced; eviction only acts on the exact next-block fee and skips its sweep otherwise. This is a simple strategy: if a tx can't pay the current fees, it gets rejected on entry and evicted when the node prepares for the next slot.
136
140
 
137
141
  **Caveat**: This may evict transactions that would become valid again if block fees drop. A more nuanced approach would be to define a threshold (e.g., 50%) and only reject/evict when the tx's max fee falls below that fraction of the current fees. The current approach is simpler and ensures the pool doesn't accumulate transactions with low max fees that are unlikely to be mined soon.
@@ -121,12 +121,13 @@ export class DataTxValidator implements TxValidator<Tx> {
121
121
  }
122
122
  }
123
123
 
124
- const expectedMinLength = 1 + tx.contractClassLogFields[i].fields.findLastIndex(f => !f.isZero());
125
- if (logHash.logHash.length < expectedMinLength) {
124
+ // The tx base requires exactly the trimmed length; any other length makes the tx unprovable.
125
+ const expectedLength = 1 + tx.contractClassLogFields[i].fields.findLastIndex(f => !f.isZero());
126
+ if (logHash.logHash.length !== expectedLength) {
126
127
  this.#log.verbose(
127
128
  `Rejecting tx ${tx
128
129
  .getTxHash()
129
- .toString()} because of incorrect contract class log length. Expected the length to be at least ${expectedMinLength}. Got ${
130
+ .toString()} because of incorrect contract class log length. Expected ${expectedLength}. Got ${
130
131
  logHash.logHash.length
131
132
  }.`,
132
133
  );