diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/README.md b/yarn-project/p2p/src/mem_pools/tx_pool_v2/README.md index c69e5ef8e9c6..3efd91ab7a3e 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/README.md +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/README.md @@ -30,19 +30,20 @@ TxPoolV2 manages transactions through a state machine with clear transitions: └─────────────────────────────────────┘ (reorg) │ │ │ handleFinalizedBlock() │ eviction after reorg + │ / eviction / failed exec │ (validation failure) ▼ ▼ ┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ -│ DELETED │ │ SOFT-DELETED │ -│ (hard-deleted or archived) │ │ (kept in DB for debugging) │ +│ SLOT-SOFT-DELETED │ │ PRUNE-SOFT-DELETED │ +│ (kept in DB until next slot) │ │ (kept in DB until finalized) │ +└─────────────────────────────────────┘ └─────────────────────────────────────┘ + │ │ + │ prepareForSlot() │ handleFinalizedBlock() + │ (slot advanced) │ (mined block finalized) + ▼ ▼ +┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ +│ HARD-DELETED │ │ HARD-DELETED │ +│ (permanently removed from DB) │ │ (permanently removed from DB) │ └─────────────────────────────────────┘ └─────────────────────────────────────┘ - │ - │ handleFinalizedBlock() - │ (mined block finalized) - ▼ - ┌─────────────────────────────────────┐ - │ HARD-DELETED │ - │ (permanently removed from DB) │ - └─────────────────────────────────────┘ ``` ## Key Components @@ -62,13 +63,11 @@ Core implementation containing: ### DeletedPool (`deleted_pool.ts`) -Manages soft deletion of transactions from pruned blocks: -- When a reorg (chain prune) occurs, transactions from pruned blocks are tracked with their original mined block number -- When these transactions are later evicted (e.g., failed validation, nullifier conflict), they are "soft-deleted" instead of removed -- Soft-deleted transactions remain in the database for debugging and potential resubmission -- When the original mined block is finalized on the new chain, soft-deleted transactions are permanently hard-deleted +Manages all transaction deletions in the pool with two soft-deletion mechanisms: +- **Slot-based**: Non-pruned txs are kept in DB until the next slot, allowing other nodes to fetch them via reqresp +- **Prune-based**: Txs from pruned blocks are kept in DB until their original mined block is finalized -This ensures transactions from reorged blocks are kept around until we're certain they won't be needed. +All deletions go through `DeletedPool.deleteTx()`, which routes to the appropriate path based on whether the tx is tracked as being from a pruned block. ### TxMetaData (`tx_metadata.ts`) @@ -86,27 +85,40 @@ Lightweight metadata stored alongside each transaction: State is derived by TxPoolIndices: - `mined` if `minedL2BlockId` is set - `protected` if in protection map -- `deleted` if soft-deleted (from a pruned block, evicted but kept in DB) +- `deleted` if soft-deleted (slot-based or prune-based, evicted but kept in DB) - `pending` otherwise ## Soft Deletion -When a chain reorganization occurs, transactions that were mined in pruned blocks are handled specially: +Deleted transactions are kept in the database for a grace period before being permanently removed. There are two soft-deletion mechanisms: + +### Slot-Based Soft Deletion + +When a transaction is deleted from the pool (eviction, validation failure, failed execution) and is **not** from a pruned block, it is "slot-soft-deleted": + +1. **Soft Delete**: The tx is removed from indices but kept in the database, tagged with the current slot number +2. **Retrieval**: Slot-soft-deleted txs can still be retrieved via `getTxByHash` and return status `'deleted'` from `getTxStatus` +3. **Hard Delete**: When `prepareForSlot` advances to a new slot, txs deleted in earlier slots are permanently removed +4. **Re-addition**: If a slot-soft-deleted tx is re-added to the pool, the slot-deleted tracking is cleared + +This allows other nodes to still fetch recently-deleted transactions via reqresp during the current slot. + +### Prune-Based Soft Deletion + +When a chain reorganization occurs, transactions that were mined in pruned blocks are handled with longer retention: 1. **Tracking**: When `handlePrunedBlocks` is called, all un-mined transactions are tracked by their original mined block number -2. **Soft Delete**: If these transactions are later evicted (failed validation, nullifier conflict, etc.), they are "soft-deleted" - removed from indices but kept in the database -3. **Retrieval**: Soft-deleted transactions can still be retrieved via `getTxByHash` and `hasTxs`, and return status `'deleted'` from `getTxStatus` +2. **Soft Delete**: If these transactions are later evicted (failed validation, nullifier conflict, etc.), they are "prune-soft-deleted" - removed from indices but kept in the database +3. **Retrieval**: Prune-soft-deleted txs can still be retrieved via `getTxByHash` and return status `'deleted'` from `getTxStatus` 4. **Hard Delete**: When `handleFinalizedBlock` is called and the finalized block number reaches or exceeds the transaction's original mined block, the transaction is permanently removed +5. **Re-addition**: If a prune-soft-deleted tx is re-added, the `softDeleted` flag is reset to `false` but the prune tracking is preserved, so a subsequent deletion still uses the prune path -This design allows: -- Debugging reorg scenarios by keeping transaction data available -- Potential resubmission of transactions that failed validation after a reorg -- Clean eventual cleanup once we're certain the transaction won't be needed +Prune-soft-deleted transactions are **not** affected by slot cleanup - they survive across slot boundaries until finalized. -**Example scenario:** +**Prune example:** 1. Tx mined at block 10 2. Chain prunes to block 5 (tx becomes un-mined, tracked as minedAtBlock=10) -3. Tx fails validation and is soft-deleted +3. Tx fails validation and is prune-soft-deleted 4. Block 9 finalized → tx still in DB (minedAtBlock=10 > finalized=9) 5. Block 10 finalized → tx hard-deleted (minedAtBlock=10 ≤ finalized=10) @@ -117,6 +129,10 @@ If the tx is re-mined at a higher block before being soft-deleted: 4. Block 10 finalized → tx still in DB 5. Block 15 finalized → tx hard-deleted +### Hydration + +On node restart, slot-soft-deleted transactions are immediately hard-deleted (they are stale by definition). Prune-soft-deleted transactions are loaded from the database and tracked normally. + ## Architecture: Pre-add vs Post-event Rules **Pre-add rules** (run during `addPendingTxs`): diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.test.ts index 23330d5c9c84..072c20614dc5 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.test.ts @@ -1,4 +1,4 @@ -import { BlockNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { createLogger } from '@aztec/foundation/log'; import type { AztecAsyncMap } from '@aztec/kv-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; @@ -120,27 +120,25 @@ describe('DeletedPool', () => { }); describe('deleteTx', () => { - it('soft-deletes tx from pruned block (keeps in DB)', async () => { + it('prune-soft-deletes tx from pruned block (keeps in DB)', async () => { await txsDB.set('tx1', Buffer.from('data1')); await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(5) }]); expect(pool.isSoftDeleted('tx1')).toBe(false); - const result = await pool.deleteTx('tx1'); + await pool.deleteTx('tx1'); - expect(result).toBe('soft'); expect(pool.isSoftDeleted('tx1')).toBe(true); expect(await txsDB.getAsync('tx1')).toBeDefined(); // Still in DB }); - it('hard-deletes tx NOT from pruned block (removes from DB)', async () => { + it('slot-soft-deletes tx NOT from pruned block (keeps in DB)', async () => { await txsDB.set('tx1', Buffer.from('data1')); // tx1 is NOT marked as from pruned block - const result = await pool.deleteTx('tx1'); + await pool.deleteTx('tx1'); - expect(result).toBe('hard'); - expect(pool.isSoftDeleted('tx1')).toBe(false); - expect(await txsDB.getAsync('tx1')).toBeUndefined(); // Removed from DB + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(await txsDB.getAsync('tx1')).toBeDefined(); }); }); @@ -208,8 +206,7 @@ describe('DeletedPool', () => { expect(pool.isFromPrunedBlock('tx1')).toBe(true); // 3. Soft-delete the tx (e.g., due to eviction) - const result = await pool.deleteTx('tx1'); - expect(result).toBe('soft'); + await pool.deleteTx('tx1'); expect(pool.isSoftDeleted('tx1')).toBe(true); expect(await txsDB.getAsync('tx1')).toBeDefined(); // Still in DB @@ -372,6 +369,158 @@ describe('DeletedPool', () => { }); }); + describe('slot-based soft deletion', () => { + it('slot-soft-deletes tx NOT from pruned block (stays in DB, isSoftDeleted true)', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + + await pool.deleteTx('tx1'); + + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(await txsDB.getAsync('tx1')).toBeDefined(); + }); + + it('prune-soft-delete takes precedence over slot-soft-delete', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(5) }]); + + await pool.deleteTx('tx1'); + + // Should use prune path, not slot path + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + }); + + it('cleanupSlotDeleted hard-deletes txs from earlier slots', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await txsDB.set('tx2', Buffer.from('data2')); + + // Delete both txs at slot 0 (default) + await pool.deleteTx('tx1'); + await pool.deleteTx('tx2'); + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(pool.isSoftDeleted('tx2')).toBe(true); + + // Advance to slot 1 - should hard-delete both (deleted at slot 0 < 1) + await pool.cleanupSlotDeleted(SlotNumber(1)); + + expect(pool.isSoftDeleted('tx1')).toBe(false); + expect(pool.isSoftDeleted('tx2')).toBe(false); + expect(await txsDB.getAsync('tx1')).toBeUndefined(); + expect(await txsDB.getAsync('tx2')).toBeUndefined(); + }); + + it('cleanupSlotDeleted preserves txs from current slot', async () => { + // Advance to slot 5 first + await pool.cleanupSlotDeleted(SlotNumber(5)); + + await txsDB.set('tx1', Buffer.from('data1')); + await pool.deleteTx('tx1'); // Deleted at slot 5 + + // Cleanup at slot 5 - should NOT hard-delete (deleted at 5, not < 5) + await pool.cleanupSlotDeleted(SlotNumber(5)); + + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(await txsDB.getAsync('tx1')).toBeDefined(); + + // Cleanup at slot 6 - NOW should hard-delete (deleted at 5 < 6) + await pool.cleanupSlotDeleted(SlotNumber(6)); + + expect(pool.isSoftDeleted('tx1')).toBe(false); + expect(await txsDB.getAsync('tx1')).toBeUndefined(); + }); + + it('cleanupSlotDeleted does NOT affect prune-soft-deleted txs', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(5) }]); + await pool.deleteTx('tx1'); // Prune-soft-deleted + + // Advance to slot 10 - should NOT affect prune-soft-deleted tx + await pool.cleanupSlotDeleted(SlotNumber(10)); + + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(await txsDB.getAsync('tx1')).toBeDefined(); + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + }); + + it('clearSoftDeleted removes tx from slot-deleted tracking', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await pool.deleteTx('tx1'); + expect(pool.isSoftDeleted('tx1')).toBe(true); + + await pool.clearSoftDeleted('tx1'); + + expect(pool.isSoftDeleted('tx1')).toBe(false); + // Tx is still in DB (clearSoftDeleted doesn't delete the tx data) + expect(await txsDB.getAsync('tx1')).toBeDefined(); + }); + + it('clearSoftDeleted resets prune softDeleted flag but preserves prune tracking', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(5) }]); + await pool.deleteTx('tx1'); + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + + await pool.clearSoftDeleted('tx1'); + + // No longer soft-deleted, but still tracked as from pruned block + expect(pool.isSoftDeleted('tx1')).toBe(false); + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + }); + + it('clearSoftDeleted is a no-op for non-soft-deleted txs', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + + // Should not throw or error + await pool.clearSoftDeleted('tx1'); + expect(pool.isSoftDeleted('tx1')).toBe(false); + }); + + it('hydration hard-deletes all slot-deleted txs', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await txsDB.set('tx2', Buffer.from('data2')); + + // Slot-delete both + await pool.deleteTx('tx1'); + await pool.deleteTx('tx2'); + expect(await txsDB.getAsync('tx1')).toBeDefined(); + expect(await txsDB.getAsync('tx2')).toBeDefined(); + + // Simulate restart + const pool2 = new DeletedPool(store, txsDB, createLogger('test2')); + await pool2.hydrateFromDatabase(); + + // Both should be hard-deleted after hydration + expect(await txsDB.getAsync('tx1')).toBeUndefined(); + expect(await txsDB.getAsync('tx2')).toBeUndefined(); + expect(pool2.isSoftDeleted('tx1')).toBe(false); + expect(pool2.isSoftDeleted('tx2')).toBe(false); + }); + + it('hydration does not affect prune-soft-deleted txs', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await txsDB.set('tx2', Buffer.from('data2')); + + // Prune-soft-delete tx1, slot-delete tx2 + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(5) }]); + await pool.deleteTx('tx1'); + await pool.deleteTx('tx2'); + + // Simulate restart + const pool2 = new DeletedPool(store, txsDB, createLogger('test2')); + await pool2.hydrateFromDatabase(); + + // tx1 (prune-soft-deleted) should still be in DB + expect(await txsDB.getAsync('tx1')).toBeDefined(); + expect(pool2.isSoftDeleted('tx1')).toBe(true); + expect(pool2.isFromPrunedBlock('tx1')).toBe(true); + + // tx2 (slot-deleted) should be hard-deleted + expect(await txsDB.getAsync('tx2')).toBeUndefined(); + expect(pool2.isSoftDeleted('tx2')).toBe(false); + }); + }); + describe('getCount and getPrunedTxHashes', () => { it('returns correct count', async () => { expect(pool.getCount()).toBe(0); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.ts index 8eeda41937fc..e4eea8793967 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.ts @@ -1,6 +1,6 @@ -import { BlockNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import type { Logger } from '@aztec/foundation/log'; -import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; +import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncSet } from '@aztec/kv-store'; /** * State stored for each transaction from a pruned block. @@ -39,15 +39,17 @@ function deserializeState(buffer: Buffer): DeletedTxState { * When a chain prune (reorg) happens, transactions from pruned blocks are tracked here. * This class is responsible for ALL deletion decisions: * - * - Transactions from pruned blocks are "soft deleted" - removed from indices but kept - * in the database for later re-execution - * - Transactions NOT from pruned blocks are "hard deleted" - completely removed from DB + * - Transactions from pruned blocks are "prune-soft-deleted" - removed from indices but kept + * in the database for later re-execution until their mined block is finalized + * - Transactions NOT from pruned blocks are "slot-soft-deleted" - kept in the database + * until the next slot, so other nodes can still fetch them via reqresp * - * When a block is finalized, soft-deleted transactions that were originally mined at or - * before that block number are permanently (hard) deleted. + * When a block is finalized, prune-soft-deleted transactions that were originally mined at or + * before that block number are permanently (hard) deleted. Slot-soft-deleted transactions + * are hard-deleted when `prepareForSlot` advances to a new slot. */ export class DeletedPool { - /** Persisted map: txHash -> DeletedTxState (serialized) */ + /** Persisted map: txHash -> DeletedTxState (serialized) - for prune-based soft deletions */ #deletedTxsDB: AztecAsyncMap; /** Reference to the main txs database for hard deletion */ @@ -56,16 +58,27 @@ export class DeletedPool { /** In-memory state for transactions from pruned blocks */ #state: Map = new Map(); + /** In-memory tracking: txHash -> slot at which the tx was deleted */ + #slotDeletedTxs: Map = new Map(); + + /** Persisted set tracking which txs are slot-deleted, for hydration cleanup. */ + #slotDeletedDB: AztecAsyncSet; + + /** Current slot number, updated by cleanupSlotDeleted */ + #currentSlot: SlotNumber = SlotNumber(0); + #log: Logger; constructor(store: AztecAsyncKVStore, txsDB: AztecAsyncMap, log: Logger) { this.#deletedTxsDB = store.openMap('deleted_txs'); + this.#slotDeletedDB = store.openSet('slot_deleted_txs'); this.#txsDB = txsDB; this.#log = log; } /** * Loads state from the database on startup. + * Slot-deleted txs are stale after restart and are immediately hard-deleted. */ async hydrateFromDatabase(): Promise { let prunedCount = 0; @@ -83,6 +96,18 @@ export class DeletedPool { if (prunedCount > 0 || softDeletedCount > 0) { this.#log.info(`Loaded ${prunedCount} txs from pruned blocks, ${softDeletedCount} soft-deleted`); } + + // Slot-deleted txs are stale after restart - hard-delete them all + let slotDeletedCount = 0; + for await (const txHash of this.#slotDeletedDB.entriesAsync()) { + await this.#txsDB.delete(txHash); + await this.#slotDeletedDB.delete(txHash); + slotDeletedCount++; + } + + if (slotDeletedCount > 0) { + this.#log.info(`Hard-deleted ${slotDeletedCount} stale slot-deleted txs on startup`); + } } /** @@ -123,27 +148,25 @@ export class DeletedPool { /** * Deletes a transaction. This is the single entry point for ALL deletions. + * The tx is always soft-deleted (kept in DB): * - * - If the tx is from a pruned block: soft-delete (keep in DB, mark as deleted) - * - If the tx is NOT from a pruned block: hard-delete (remove from DB) - * - * @returns 'soft' if soft-deleted, 'hard' if hard-deleted + * - If the tx is from a pruned block: prune-soft-delete (kept until finalized) + * - If the tx is NOT from a pruned block: slot-soft-delete (kept until next slot) */ - async deleteTx(txHash: string): Promise<'soft' | 'hard'> { + async deleteTx(txHash: string): Promise { const existing = this.#state.get(txHash); if (existing !== undefined) { - // Soft delete - keep in DB + // Prune-soft-delete - keep in DB until finalized const state: DeletedTxState = { minedAtBlock: existing.minedAtBlock, softDeleted: true, }; this.#state.set(txHash, state); await this.#deletedTxsDB.set(txHash, serializeState(state)); - return 'soft'; } else { - // Hard delete - remove from DB - await this.#txsDB.delete(txHash); - return 'hard'; + // Slot-soft-delete - keep in DB until next slot + this.#slotDeletedTxs.set(txHash, this.#currentSlot); + await this.#slotDeletedDB.add(txHash); } } @@ -176,10 +199,10 @@ export class DeletedPool { } /** - * Checks if a transaction is soft-deleted. + * Checks if a transaction is soft-deleted (either prune-based or slot-based). */ isSoftDeleted(txHash: string): boolean { - return this.#state.get(txHash)?.softDeleted ?? false; + return (this.#state.get(txHash)?.softDeleted ?? false) || this.#slotDeletedTxs.has(txHash); } /** @@ -218,6 +241,56 @@ export class DeletedPool { return toHardDelete; } + /** + * Cleans up slot-deleted transactions from previous slots. + * Called at the start of prepareForSlot. Updates #currentSlot and hard-deletes + * any txs that were deleted in an earlier slot. + */ + async cleanupSlotDeleted(currentSlot: SlotNumber): Promise { + const previousSlot = this.#currentSlot; + this.#currentSlot = currentSlot; + + const toHardDelete: string[] = []; + for (const [txHash, deletedAtSlot] of this.#slotDeletedTxs) { + if (deletedAtSlot < currentSlot) { + toHardDelete.push(txHash); + } + } + + if (toHardDelete.length === 0) { + return; + } + + for (const txHash of toHardDelete) { + this.#slotDeletedTxs.delete(txHash); + await this.#slotDeletedDB.delete(txHash); + await this.#txsDB.delete(txHash); + } + + this.#log.debug( + `Cleaned up ${toHardDelete.length} slot-deleted txs from slot ${previousSlot} (now slot ${currentSlot})`, + ); + } + + /** + * Clears soft-deletion status for a transaction being re-added to the pool. + * Removes slot-deleted tracking entirely, and resets the prune-soft-deleted flag + * while preserving the prune tracking itself (so a subsequent delete still uses + * the prune path). + */ + async clearSoftDeleted(txHash: string): Promise { + if (this.#slotDeletedTxs.has(txHash)) { + this.#slotDeletedTxs.delete(txHash); + await this.#slotDeletedDB.delete(txHash); + } + const existing = this.#state.get(txHash); + if (existing?.softDeleted) { + const state: DeletedTxState = { minedAtBlock: existing.minedAtBlock, softDeleted: false }; + this.#state.set(txHash, state); + await this.#deletedTxsDB.set(txHash, serializeState(state)); + } + } + /** * Gets the count of transactions from pruned blocks. */ diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts index 8b76a026d01a..7f0e52312729 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts @@ -161,10 +161,10 @@ describe('TxPoolV2 Compatibility Tests', () => { await pool.addPendingTxs([pendingTx, minedTx]); await pool.handleMinedBlock(makeBlock([minedTx], block1Header)); - // Delete a pending tx via handleFailedExecution - should be permanently deleted + // Delete a pending tx via handleFailedExecution - should be slot-soft-deleted await pool.handleFailedExecution([pendingTx.getTxHash()]); - expect(await pool.getTxByHash(pendingTx.getTxHash())).toBeUndefined(); - expect(await pool.getTxStatus(pendingTx.getTxHash())).toBeUndefined(); + expect(await pool.getTxByHash(pendingTx.getTxHash())).toBeDefined(); + expect(await pool.getTxStatus(pendingTx.getTxHash())).toBe('deleted'); expect(await pool.getPendingTxCount()).toEqual(0); }); @@ -305,8 +305,8 @@ describe('TxPoolV2 Compatibility Tests', () => { // Delete mined tx via finalization await pool.handleFinalizedBlock(block1Header); - // Verify mined tx is deleted - expect(await pool.getTxStatus(txs[0].getTxHash())).toBeUndefined(); + // Verify mined tx is deleted (slot-soft-deleted) + expect(await pool.getTxStatus(txs[0].getTxHash())).toBe('deleted'); // Verify remaining pending count expect(await pool.getPendingTxCount()).toBe(2); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts index 7b943c56d6b5..d226f41aa10f 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts @@ -1043,7 +1043,7 @@ describe('TxPoolV2', () => { expect(toStrings(result.accepted)).toContain(hashOf(txPreProtected)); // txExisting was evicted by txNormal (normal eviction still works) - expect(await pool.getTxStatus(txExisting.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txExisting.getTxHash())).toBe('deleted'); expect(await pool.getTxStatus(txNormal.getTxHash())).toBe('pending'); expect(await pool.getTxStatus(txPreProtected.getTxHash())).toBe('protected'); }); @@ -1073,7 +1073,7 @@ describe('TxPoolV2', () => { // Pool should have: tx2 (200), txNormal (150), txPreProtected (1 - protected) // tx1 (100) was evicted to make room for txNormal - expect(await pool.getTxStatus(tx1.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('deleted'); expect(await pool.getTxStatus(tx2.getTxHash())).toBe('pending'); expect(await pool.getTxStatus(txNormal.getTxHash())).toBe('pending'); expect(await pool.getTxStatus(txPreProtected.getTxHash())).toBe('protected'); @@ -1211,7 +1211,7 @@ describe('TxPoolV2', () => { // High priority tx should now be pending, low priority tx should be deleted expect(await pool.getTxStatus(txHigh.getTxHash())).toBe('pending'); - expect(await pool.getTxStatus(txLow.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txLow.getTxHash())).toBe('deleted'); expect(await pool.getPendingTxCount()).toBe(1); }); }); @@ -1356,7 +1356,7 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(txProtected)); - expect(await pool.getTxStatus(txPending.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txPending.getTxHash())).toBe('deleted'); expectRemovedTxs(txPending); // txPending evicted due to nullifier conflict }); @@ -1382,7 +1382,7 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(txPending)); - expect(await pool.getTxStatus(txProtected.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txProtected.getTxHash())).toBe('deleted'); expectRemovedTxs(txProtected); // txProtected deleted due to lower priority }); @@ -1405,8 +1405,8 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(tx2)); // tx2 has fee=15, highest - expect(await pool.getTxStatus(tx1.getTxHash())).toBeUndefined(); - expect(await pool.getTxStatus(tx3.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('deleted'); + expect(await pool.getTxStatus(tx3.getTxHash())).toBe('deleted'); expectRemovedTxs(tx1, tx3); // Lower priority txs deleted }); @@ -1434,8 +1434,8 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(txProtected)); - expect(await pool.getTxStatus(txPending1.getTxHash())).toBeUndefined(); - expect(await pool.getTxStatus(txPending2.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txPending1.getTxHash())).toBe('deleted'); + expect(await pool.getTxStatus(txPending2.getTxHash())).toBe('deleted'); expectRemovedTxs(txPending1, txPending2); // Both evicted }); @@ -1463,7 +1463,7 @@ describe('TxPoolV2', () => { expect(pending).toHaveLength(2); expect(pending).toContain(hashOf(txPendingHigh)); expect(pending).toContain(hashOf(txPendingLow)); - expect(await pool.getTxStatus(txProtected.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txProtected.getTxHash())).toBe('deleted'); expectRemovedTxs(txProtected); // txProtected deleted }); }); @@ -1512,8 +1512,8 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(txMined)); - // txPending was never mined, never in a pruned block, so it's hard-deleted - expect(await pool.getTxStatus(txPending.getTxHash())).toBeUndefined(); + // txPending was never mined, never in a pruned block, so it's slot-soft-deleted + expect(await pool.getTxStatus(txPending.getTxHash())).toBe('deleted'); expectRemovedTxs(txPending); // txPending evicted due to nullifier conflict }); @@ -1615,9 +1615,9 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(txMined)); - // txPending1 and txPending2 were never mined, never in a pruned block, so hard-deleted - expect(await pool.getTxStatus(txPending1.getTxHash())).toBeUndefined(); - expect(await pool.getTxStatus(txPending2.getTxHash())).toBeUndefined(); + // txPending1 and txPending2 were never mined, never in a pruned block, so slot-soft-deleted + expect(await pool.getTxStatus(txPending1.getTxHash())).toBe('deleted'); + expect(await pool.getTxStatus(txPending2.getTxHash())).toBe('deleted'); expectRemovedTxs(txPending1, txPending2); // Both evicted }); @@ -1697,7 +1697,7 @@ describe('TxPoolV2', () => { // Unprotect - tx should be deleted due to validation failure await poolWithValidator.prepareForSlot(SlotNumber(2)); - expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBeUndefined(); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); expect(await poolWithValidator.getPendingTxCount()).toBe(0); }); @@ -1733,7 +1733,7 @@ describe('TxPoolV2', () => { await poolWithValidator.prepareForSlot(SlotNumber(2)); expect(await poolWithValidator.getTxStatus(txValid.getTxHash())).toBe('pending'); - expect(await poolWithValidator.getTxStatus(txInvalid.getTxHash())).toBeUndefined(); + expect(await poolWithValidator.getTxStatus(txInvalid.getTxHash())).toBe('deleted'); expect(await poolWithValidator.getTxStatus(txAlsoValid.getTxHash())).toBe('pending'); expect(await poolWithValidator.getPendingTxCount()).toBe(2); }); @@ -1832,7 +1832,7 @@ describe('TxPoolV2', () => { const pending = toStrings(await poolWithValidator.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(txPending)); - expect(await poolWithValidator.getTxStatus(txProtected.getTxHash())).toBeUndefined(); + expect(await poolWithValidator.getTxStatus(txProtected.getTxHash())).toBe('deleted'); }); }); @@ -2270,7 +2270,7 @@ describe('TxPoolV2', () => { expect(await poolWithValidator.getTxStatus(txHigherPriority.getTxHash())).toBe('pending'); }); - it('tx not in pruned block that is deleted should be hard-deleted', async () => { + it('tx not in pruned block that is deleted should be slot-soft-deleted', async () => { const tx = await mockTx(1); // Add tx as pending (never mined, so never pruned) @@ -2287,9 +2287,9 @@ describe('TxPoolV2', () => { await poolWithValidator.addProtectedTxs([tx], slot1Header); await poolWithValidator.prepareForSlot(SlotNumber(2)); - // The tx was never in a pruned block, so it should be HARD-deleted - expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBeUndefined(); - expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeUndefined(); + // The tx was never in a pruned block, so it should be slot-soft-deleted + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); }); }); }); @@ -2302,7 +2302,7 @@ describe('TxPoolV2', () => { await pool.handleFailedExecution([tx.getTxHash()]); - expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); expect(await pool.getPendingTxCount()).toBe(0); expectRemovedTxs(tx); }); @@ -2339,8 +2339,8 @@ describe('TxPoolV2', () => { await pool.handleFinalizedBlock(slot1Header); - expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); - expect(await pool.getTxByHash(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await pool.getTxByHash(tx.getTxHash())).toBeDefined(); expectRemovedTxs(tx); // Now the tx is actually deleted }); @@ -2354,7 +2354,7 @@ describe('TxPoolV2', () => { await pool.handleFinalizedBlock(slot1Header); - expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); const archived = await pool.getArchivedTxByHash(tx.getTxHash()); expect(archived).toBeDefined(); expect(archived!.getTxHash().toString()).toEqual(hashOf(tx)); @@ -2380,7 +2380,7 @@ describe('TxPoolV2', () => { await pool.handleFinalizedBlock(slot1Header); expectRemovedTxs(tx); // Actually deleted - expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); }); it('pending -> protected -> pending (slot passed)', async () => { @@ -2429,7 +2429,7 @@ describe('TxPoolV2', () => { await pool.handleFinalizedBlock(slot1Header); expectRemovedTxs(tx); // Actually deleted - expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); }); it('N/A -> mined -> deleted (prover flow)', async () => { @@ -2441,7 +2441,7 @@ describe('TxPoolV2', () => { await pool.handleFinalizedBlock(slot1Header); expectRemovedTxs(tx); // Actually deleted - expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); }); }); @@ -2709,7 +2709,7 @@ describe('TxPoolV2', () => { expect(toStrings(result.accepted)).toContain(hashOf(txHigh)); expect(result.rejected).toHaveLength(0); expect(await pool.getPendingTxCount()).toBe(1); - expect(await pool.getTxStatus(txLow.getTxHash())).toBeUndefined(); // evicted + expect(await pool.getTxStatus(txLow.getTxHash())).toBe('deleted'); // evicted expect(await pool.getTxStatus(txHigh.getTxHash())).toBe('pending'); }); @@ -2827,7 +2827,7 @@ describe('TxPoolV2', () => { // txMed (higher priority) should remain pending expect(await pool.getTxStatus(txMed.getTxHash())).toBe('pending'); // txLow (lower priority) should be evicted due to insufficient balance - expect(await pool.getTxStatus(txLow.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txLow.getTxHash())).toBe('deleted'); }); it('evicts low-priority txs after CHAIN_PRUNED when balance is insufficient', async () => { @@ -3116,7 +3116,7 @@ describe('TxPoolV2', () => { await pool.handleMinedBlock(makeBlock([txToMine], slot1Header)); // txPending should be evicted (nullifier conflict with mined tx) - expect(await pool.getTxStatus(txPending.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txPending.getTxHash())).toBe('deleted'); // txToMine should be mined expect(await pool.getTxStatus(txToMine.getTxHash())).toBe('mined'); }); @@ -3143,7 +3143,7 @@ describe('TxPoolV2', () => { await pool.handleMinedBlock(makeBlock([txUnknown], slot1Header)); // txPending should be evicted because the block contains a conflicting nullifier - expect(await pool.getTxStatus(txPending.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txPending.getTxHash())).toBe('deleted'); // txUnknown should NOT be in the pool (we never added it) expect(await pool.getTxStatus(txUnknown.getTxHash())).toBeUndefined(); }); @@ -3166,8 +3166,8 @@ describe('TxPoolV2', () => { // Mine block with unknown txs - tx1 and tx2 should be evicted, tx3 should remain await pool.handleMinedBlock(makeBlock([unknownTx1, unknownTx2], slot1Header)); - expect(await pool.getTxStatus(tx1.getTxHash())).toBeUndefined(); - expect(await pool.getTxStatus(tx2.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('deleted'); + expect(await pool.getTxStatus(tx2.getTxHash())).toBe('deleted'); expect(await pool.getTxStatus(tx3.getTxHash())).toBe('pending'); expect(await pool.getPendingTxCount()).toBe(1); }); @@ -3187,7 +3187,7 @@ describe('TxPoolV2', () => { await pool.handleMinedBlock(makeBlock([txUnknown], slot1Header)); // txPending should be evicted even though only the second nullifier conflicts - expect(await pool.getTxStatus(txPending.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txPending.getTxHash())).toBe('deleted'); }); it('does not evict protected txs when block contains conflicting nullifiers', async () => { @@ -3280,7 +3280,7 @@ describe('TxPoolV2', () => { await pool.addPendingTxs([tx4]); expect(await pool.getPendingTxCount()).toBe(3); - expect(await pool.getTxStatus(txs[0].getTxHash())).toBeUndefined(); // fee=10 evicted + expect(await pool.getTxStatus(txs[0].getTxHash())).toBe('deleted'); // fee=10 evicted expect(await pool.getTxStatus(tx4.getTxHash())).toBe('pending'); // fee=15 kept }); @@ -3395,8 +3395,8 @@ describe('TxPoolV2', () => { await pool.handleMinedBlock(makeBlock([tx], slot1Header)); await pool.handleFinalizedBlock(slot1Header); - expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); - expect(await pool.getTxByHash(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await pool.getTxByHash(tx.getTxHash())).toBeDefined(); }); it('handles duplicate handleMinedBlock calls', async () => { @@ -3836,7 +3836,7 @@ describe('TxPoolV2', () => { expect(result.ignored).toHaveLength(0); expect(result.rejected).toHaveLength(0); expect(await pool.getPendingTxCount()).toBe(2); - expect(await pool.getTxStatus(tx1.getTxHash())).toBeUndefined(); // evicted + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('deleted'); // evicted expect(await pool.getTxStatus(tx2.getTxHash())).toBe('pending'); expect(await pool.getTxStatus(tx3.getTxHash())).toBe('pending'); }); @@ -3910,7 +3910,7 @@ describe('TxPoolV2', () => { expect(result.ignored).toHaveLength(0); expect(result.rejected).toHaveLength(0); expect(await pool.getPendingTxCount()).toBe(1); - expect(await pool.getTxStatus(txLow.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txLow.getTxHash())).toBe('deleted'); expect(await pool.getTxStatus(txHigh.getTxHash())).toBe('pending'); }); @@ -4424,4 +4424,237 @@ describe('TxPoolV2', () => { expect(await pool.getMinedTxCount()).toBe(0); }); }); + + describe('slot-based soft deletion', () => { + it('deleted tx is retrievable and has deleted status within the same slot', async () => { + const tx = await mockTx(1); + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + + await pool.handleFailedExecution([tx.getTxHash()]); + expectRemovedTxs(tx); + + // Tx is soft-deleted: status is 'deleted' but still in DB + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await pool.getTxByHash(tx.getTxHash())).toBeDefined(); + }); + + it('prepareForSlot hard-deletes txs from previous slots', async () => { + const tx = await mockTx(1); + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + + // Delete in slot 1 + await pool.prepareForSlot(SlotNumber(1)); + await pool.handleFailedExecution([tx.getTxHash()]); + expectRemovedTxs(tx); + + // Still retrievable in same slot + expect(await pool.getTxByHash(tx.getTxHash())).toBeDefined(); + + // Advance to slot 2 - should hard-delete + await pool.prepareForSlot(SlotNumber(2)); + + expect(await pool.getTxByHash(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); + }); + + it('prepareForSlot with same slot preserves current-slot deletions', async () => { + const tx = await mockTx(1); + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + + await pool.prepareForSlot(SlotNumber(1)); + await pool.handleFailedExecution([tx.getTxHash()]); + expectRemovedTxs(tx); + + // Call prepareForSlot again with same slot number + await pool.prepareForSlot(SlotNumber(1)); + + // Tx should still be retrievable (same slot) + expect(await pool.getTxByHash(tx.getTxHash())).toBeDefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + }); + + it('evicted tx is retrievable until next slot', async () => { + // Setup pool with size limit of 1 + await pool.updateConfig({ maxPendingTxCount: 1 }); + + const tx1 = await mockTxWithFee(1, 10); + const tx2 = await mockTxWithFee(2, 20); + + await pool.prepareForSlot(SlotNumber(1)); + await pool.addPendingTxs([tx1]); + expectAddedTxs(tx1); + + // tx2 has higher fee, so tx1 gets evicted + await pool.addPendingTxs([tx2]); + expectAddedTxs(tx2); + expectRemovedTxs(tx1); + + // Evicted tx1 is still retrievable (slot-soft-deleted) + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('deleted'); + expect(await pool.getTxByHash(tx1.getTxHash())).toBeDefined(); + + // Advance slot - tx1 should be hard-deleted + await pool.prepareForSlot(SlotNumber(2)); + expect(await pool.getTxByHash(tx1.getTxHash())).toBeUndefined(); + }); + + it('re-added tx after slot-soft-delete is not cleaned up by prepareForSlot', async () => { + const tx = await mockTx(1); + + await pool.prepareForSlot(SlotNumber(1)); + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + + // Delete in slot 1 + await pool.handleFailedExecution([tx.getTxHash()]); + expectRemovedTxs(tx); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + + // Re-add while still soft deleted + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('pending'); + + // Advance to slot 2 - tx should NOT be cleaned up since it was re-added + await pool.prepareForSlot(SlotNumber(2)); + + expect(await pool.getTxStatus(tx.getTxHash())).toBe('pending'); + expect(await pool.getTxByHash(tx.getTxHash())).toBeDefined(); + expect(await pool.getPendingTxCount()).toBe(1); + }); + + it('re-added tx after prune-soft-delete is not cleaned up by handleFinalizedBlock', async () => { + const tx = await mockTx(1); + + // Add, mine at block 1, prune + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + await pool.handleMinedBlock(makeBlock([tx], slot1Header)); + expectNoCallbacks(); + await pool.handlePrunedBlocks(block0Id); + + // Tx is restored to pending (valid by default) + expect(await pool.getTxStatus(tx.getTxHash())).toBe('pending'); + + // Delete the tx + await pool.handleFailedExecution([tx.getTxHash()]); + expectRemovedTxs(tx); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + + // Re-add the tx + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('pending'); + + // Finalize block 1 - should NOT delete the re-added tx + await pool.handleFinalizedBlock(slot1Header); + + expect(await pool.getTxStatus(tx.getTxHash())).toBe('pending'); + expect(await pool.getTxByHash(tx.getTxHash())).toBeDefined(); + expect(await pool.getPendingTxCount()).toBe(1); + }); + + it('re-added then re-deleted prune tx remains prune-soft-deleted until finalized', async () => { + const tx = await mockTx(1); + + // Add, mine at block 1, prune + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + await pool.handleMinedBlock(makeBlock([tx], slot1Header)); + expectNoCallbacks(); + await pool.handlePrunedBlocks(block0Id); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('pending'); + + // Delete, re-add, delete again + await pool.handleFailedExecution([tx.getTxHash()]); + expectRemovedTxs(tx); + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + await pool.handleFailedExecution([tx.getTxHash()]); + expectRemovedTxs(tx); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + + // Advance slot - tx should survive because it's prune-soft-deleted, not slot-soft-deleted + await pool.prepareForSlot(SlotNumber(1)); + await pool.prepareForSlot(SlotNumber(2)); + await pool.prepareForSlot(SlotNumber(3)); + + // Still retrievable (prune-soft-deleted, not affected by slot cleanup) + expect(await pool.getTxByHash(tx.getTxHash())).toBeDefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + + // Finalize block 1 - now the tx should be hard-deleted + await pool.handleFinalizedBlock(slot1Header); + + expect(await pool.getTxByHash(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); + }); + + it('prune-soft-deleted tx is not affected by slot cleanup', async () => { + const mockValidator = mock>(); + mockValidator.validateTx.mockResolvedValue({ result: 'valid' }); + const validatorStore = await openTmpStore('p2p-slot-prune'); + const validatorArchiveStore = await openTmpStore('archive-slot-prune'); + const poolWithValidator = new AztecKVTxPoolV2(validatorStore, validatorArchiveStore, { + l2BlockSource: mockL2BlockSource, + worldStateSynchronizer: mockWorldState, + createTxValidator: () => Promise.resolve(mockValidator), + }); + await poolWithValidator.start(); + + try { + const tx = await mockTx(1); + + // Add, mine, prune with rejection + await poolWithValidator.addPendingTxs([tx]); + await poolWithValidator.handleMinedBlock(makeBlock([tx], slot1Header)); + + mockValidator.validateTx.mockResolvedValue({ result: 'invalid', reason: ['expired'] }); + await poolWithValidator.handlePrunedBlocks(block0Id); + + // Tx is prune-soft-deleted + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); + + // Advance many slots + await poolWithValidator.prepareForSlot(SlotNumber(5)); + await poolWithValidator.prepareForSlot(SlotNumber(10)); + + // Still present - prune deletions are not cleaned up by slot advancement + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); + + // Only finalization cleans it up + await poolWithValidator.handleFinalizedBlock(slot1Header); + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeUndefined(); + } finally { + await poolWithValidator.stop(); + await validatorStore.delete(); + await validatorArchiveStore.delete(); + } + }); + + it('finalized mined tx is slot-soft-deleted and cleaned next slot', async () => { + const tx = await mockTx(1); + await pool.addPendingTxs([tx]); + expectAddedTxs(tx); + await pool.handleMinedBlock(makeBlock([tx], slot1Header)); + + await pool.prepareForSlot(SlotNumber(1)); + await pool.handleFinalizedBlock(slot1Header); + expectRemovedTxs(tx); + + // Tx is slot-soft-deleted (was never pruned, so uses slot path) + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await pool.getTxByHash(tx.getTxHash())).toBeDefined(); + + // Advance slot - hard-deleted + await pool.prepareForSlot(SlotNumber(2)); + expect(await pool.getTxByHash(tx.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); + }); + }); }); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts index 54bfb3b7ec0c..69908067396c 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts @@ -393,6 +393,9 @@ export class TxPoolV2Impl { } async prepareForSlot(slotNumber: SlotNumber): Promise { + // Step 0: Clean up slot-deleted txs from previous slots + await this.#deletedPool.cleanupSlotDeleted(slotNumber); + // Step 1: Find expired protected txs const expiredProtected = this.#indices.findExpiredProtectedTxs(slotNumber); @@ -631,6 +634,7 @@ export class TxPoolV2Impl { const meta = await buildTxMetaData(tx); await this.#txsDB.set(txHashStr, tx.toBuffer()); + await this.#deletedPool.clearSoftDeleted(txHashStr); this.#callbacks.onTxsAdded([tx], opts); if (state === 'pending') {