diff --git a/yarn-project/p2p/src/client/p2p_client.test.ts b/yarn-project/p2p/src/client/p2p_client.test.ts index 25200df67014..aa670f6da934 100644 --- a/yarn-project/p2p/src/client/p2p_client.test.ts +++ b/yarn-project/p2p/src/client/p2p_client.test.ts @@ -275,19 +275,58 @@ describe('P2P Client', () => { }); describe('Chain prunes', () => { - it('calls handlePrunedBlocks when chain is pruned', async () => { + it('passes deleteAllTxs: false when prune does not cross a checkpoint boundary', async () => { + client = createClient({ txPoolDeleteTxsAfterReorg: true }); blockSource.setProvenBlockNumber(0); + // Only checkpoint up to block 90 — blocks 91-100 are proposed but not checkpointed + blockSource.setCheckpointedBlockNumber(90); await client.start(); - // Prune the chain back to block 90 + // Prune 5 blocks (91-100): checkpointed tip stays at checkpoint 90 blockSource.removeBlocks(10); await client.sync(); - // Verify handlePrunedBlocks is called with the correct block ID - expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith({ - number: BlockNumber(90), - hash: expect.any(String), - }); + expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith( + { number: BlockNumber(90), hash: expect.any(String) }, + { deleteAllTxs: false }, + ); + await client.stop(); + }); + + it('passes deleteAllTxs: true when prune crosses a checkpoint boundary', async () => { + client = createClient({ txPoolDeleteTxsAfterReorg: true }); + blockSource.setProvenBlockNumber(0); + // Checkpoint all 100 blocks + blockSource.setCheckpointedBlockNumber(100); + await client.start(); + + // Prune 5 blocks (96-100): checkpointed tip moves from checkpoint 100 to 95 + blockSource.removeBlocks(5); + await client.sync(); + + expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith( + { number: BlockNumber(95), hash: expect.any(String) }, + { deleteAllTxs: true }, + ); + await client.stop(); + }); + + it('passes deleteAllTxs: false for cross-checkpoint prune when txPoolDeleteTxsAfterReorg is disabled', async () => { + // Default config has txPoolDeleteTxsAfterReorg: false + blockSource.setProvenBlockNumber(0); + // Checkpoint all 100 blocks + blockSource.setCheckpointedBlockNumber(100); + await client.start(); + + // Prune 5 blocks (96-100): checkpointed tip moves from checkpoint 100 to 95 + blockSource.removeBlocks(5); + await client.sync(); + + // Should delete all txs but flag is off + expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith( + { number: BlockNumber(95), hash: expect.any(String) }, + { deleteAllTxs: false }, + ); await client.stop(); }); diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index 4bcfa04539a3..04191b75d937 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -1,12 +1,13 @@ import { GENESIS_BLOCK_HEADER_HASH } from '@aztec/constants'; import type { EpochCacheInterface } from '@aztec/epoch-cache'; -import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { createLogger } from '@aztec/foundation/log'; import { RunningPromise } from '@aztec/foundation/promise'; import { DateProvider } from '@aztec/foundation/timer'; import type { AztecAsyncKVStore, AztecAsyncSingleton } from '@aztec/kv-store'; import { L2TipsKVStore } from '@aztec/kv-store/stores'; import { + type CheckpointId, type EthAddress, type L2Block, type L2BlockId, @@ -200,7 +201,7 @@ export class P2PClient break; case 'chain-pruned': this.txCollection.stopCollectingForBlocksAfter(event.block.number); - await this.handlePruneL2Blocks(event.block); + await this.handlePruneL2Blocks(event.block, event.checkpoint); break; case 'chain-checkpointed': break; @@ -750,10 +751,31 @@ export class P2PClient /** * Updates the tx pool after a chain prune. + * Detects epoch prunes (checkpoint number changed) and deletes all txs in that case. * @param latestBlock - The block ID the chain was pruned to. + * @param newCheckpoint - The checkpoint ID after the prune. */ - private async handlePruneL2Blocks(latestBlock: L2BlockId): Promise { - await this.txPool.handlePrunedBlocks(latestBlock); + private async handlePruneL2Blocks(latestBlock: L2BlockId, newCheckpoint: CheckpointId): Promise { + const deleteAllTxs = this.config.txPoolDeleteTxsAfterReorg && (await this.isEpochPrune(newCheckpoint)); + await this.txPool.handlePrunedBlocks(latestBlock, { deleteAllTxs }); + } + + /** + * Returns true if the prune crossed a checkpoint boundary. + * If the old and new checkpoint numbers are the same, the prune is within a single checkpoint. + * If they differ, the prune spans across checkpoints (epoch prune). + */ + private async isEpochPrune(newCheckpoint: CheckpointId): Promise { + const tips = await this.l2Tips.getL2Tips(); + const oldCheckpointNumber = tips.checkpointed.checkpoint.number; + if (oldCheckpointNumber <= CheckpointNumber.ZERO) { + return false; + } + const isEpochPrune = oldCheckpointNumber !== newCheckpoint.number; + this.log.info( + `Detected epoch prune: ${isEpochPrune}. Old checkpoint: ${oldCheckpointNumber}, new checkpoint: ${newCheckpoint.number}`, + ); + return isEpochPrune; } /** Checks if the slot has changed and calls prepareForSlot if so. */ diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts index 908e42a1eee7..c8cd31b5389a 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts @@ -162,7 +162,7 @@ export interface TxPoolV2 extends TypedEventEmitter { * and validates them before returning to pending. * @param latestBlock - The latest valid block ID after the prune */ - handlePrunedBlocks(latestBlock: L2BlockId): Promise; + handlePrunedBlocks(latestBlock: L2BlockId, options?: { deleteAllTxs?: boolean }): Promise; /** * Handles failed transaction execution. 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 200d8e16709c..ae79850d6c40 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 @@ -1623,6 +1623,27 @@ describe('TxPoolV2', () => { expectNoCallbacks(); // handlePrunedBlocks restores to pending, no removal }); + it('deleteAllTxs option deletes all un-mined txs instead of restoring to pending', async () => { + const tx1 = await mockTx(1); + const tx2 = await mockTx(2); + await pool.addPendingTxs([tx1, tx2]); + expectAddedTxs(tx1, tx2); + + // Mine both txs + await pool.handleMinedBlock(makeBlock([tx1, tx2], slot1Header)); + expectNoCallbacks(); + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('mined'); + expect(await pool.getTxStatus(tx2.getTxHash())).toBe('mined'); + + // Prune with deleteAllTxs - should delete all instead of restoring to pending + await pool.handlePrunedBlocks(block0Id, { deleteAllTxs: true }); + + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('deleted'); + expect(await pool.getTxStatus(tx2.getTxHash())).toBe('deleted'); + expect(await pool.getPendingTxCount()).toBe(0); + expectRemovedTxs(tx1, tx2); + }); + it('un-mined tx with higher priority evicts conflicting pending tx', async () => { // Ensure anchor block is valid db.findLeafIndices.mockResolvedValue([1n]); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts index 42d8a39406be..c8ec01271baf 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts @@ -100,8 +100,8 @@ export class AztecKVTxPoolV2 extends (EventEmitter as new () => TypedEventEmitte return this.#queue.put(() => this.#impl.prepareForSlot(slotNumber)); } - handlePrunedBlocks(latestBlock: L2BlockId): Promise { - return this.#queue.put(() => this.#impl.handlePrunedBlocks(latestBlock)); + handlePrunedBlocks(latestBlock: L2BlockId, options?: { deleteAllTxs?: boolean }): Promise { + return this.#queue.put(() => this.#impl.handlePrunedBlocks(latestBlock, options)); } handleFailedExecution(txHashes: TxHash[]): Promise { 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 73a16d061228..ba2c0a9abed6 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 @@ -510,7 +510,7 @@ export class TxPoolV2Impl { } } - async handlePrunedBlocks(latestBlock: L2BlockId): Promise { + async handlePrunedBlocks(latestBlock: L2BlockId, options?: { deleteAllTxs?: boolean }): Promise { // Step 1: Find transactions mined after the prune point const txsToUnmine = this.#indices.findTxsMinedAfter(latestBlock.number); if (txsToUnmine.length === 0) { @@ -535,10 +535,20 @@ export class TxPoolV2Impl { this.#indices.markAsUnmined(meta); } + // If deleteAllTxs is set (epoch prune), delete all un-mined txs and return early + if (options?.deleteAllTxs) { + const allTxHashes = txsToUnmine.map(m => m.txHash); + await this.#deleteTxsBatch(allTxHashes); + this.#log.info( + `Handled prune to block ${latestBlock.number} with deleteAllTxs: deleted ${allTxHashes.length} txs`, + ); + return; + } + // Step 4: Filter out protected txs (they'll be handled by prepareForSlot) const unprotectedTxs = this.#indices.filterUnprotected(txsToUnmine); - // Step 4: Validate for pending pool + // Step 5: Validate for pending pool const { valid, invalid } = await this.#revalidateMetadata(unprotectedTxs, 'during handlePrunedBlocks'); // Step 6: Resolve nullifier conflicts and add winners to pending indices diff --git a/yarn-project/p2p/src/test-helpers/testbench-utils.ts b/yarn-project/p2p/src/test-helpers/testbench-utils.ts index cfa6a923fcb8..ed9e8f3567f4 100644 --- a/yarn-project/p2p/src/test-helpers/testbench-utils.ts +++ b/yarn-project/p2p/src/test-helpers/testbench-utils.ts @@ -123,7 +123,7 @@ export class InMemoryTxPool extends EventEmitter implements TxPoolV2 { return Promise.resolve(); } - handlePrunedBlocks(_latestBlock: L2BlockId): Promise { + handlePrunedBlocks(_latestBlock: L2BlockId, _options?: { deleteAllTxs?: boolean }): Promise { return Promise.resolve(); }