From 603ad7011f7c4f483e0d451c6769583313f6274d Mon Sep 17 00:00:00 2001 From: Phil Windle Date: Tue, 17 Feb 2026 16:06:33 +0000 Subject: [PATCH 1/4] Add flag to delete all prune transactions --- .../p2p/src/client/p2p_client.test.ts | 10 ++++---- yarn-project/p2p/src/client/p2p_client.ts | 24 +++++++++++++++---- .../src/mem_pools/tx_pool_v2/interfaces.ts | 2 +- .../mem_pools/tx_pool_v2/tx_pool_v2.test.ts | 21 ++++++++++++++++ .../src/mem_pools/tx_pool_v2/tx_pool_v2.ts | 4 ++-- .../mem_pools/tx_pool_v2/tx_pool_v2_impl.ts | 14 +++++++++-- .../p2p/src/test-helpers/testbench-utils.ts | 2 +- 7 files changed, 62 insertions(+), 15 deletions(-) diff --git a/yarn-project/p2p/src/client/p2p_client.test.ts b/yarn-project/p2p/src/client/p2p_client.test.ts index 25200df67014..413cda74dc8e 100644 --- a/yarn-project/p2p/src/client/p2p_client.test.ts +++ b/yarn-project/p2p/src/client/p2p_client.test.ts @@ -283,11 +283,11 @@ describe('P2P Client', () => { 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), - }); + // Verify handlePrunedBlocks is called with the correct block ID and options + expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith( + { number: BlockNumber(90), hash: expect.any(String) }, + expect.objectContaining({ deleteAllTxs: expect.any(Boolean) }), + ); 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..5d9c54d044e5 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, @@ -88,6 +89,9 @@ export class P2PClient /** Tracks the last slot for which we called prepareForSlot */ private lastSlotProcessed: SlotNumber = SlotNumber.ZERO; + /** Tracks the checkpoint number of the latest mined block, used for epoch prune detection */ + private latestMinedCheckpointNumber: CheckpointNumber = CheckpointNumber.ZERO; + /** Polls for slot changes and calls prepareForSlot on the tx pool */ private slotMonitor: RunningPromise | undefined; @@ -200,7 +204,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; @@ -700,6 +704,7 @@ export class P2PClient await this.maybeCallPrepareForSlot(); await this.startCollectingMissingTxs(blocks); const lastBlock = blocks.at(-1)!; + this.latestMinedCheckpointNumber = lastBlock.checkpointNumber; await this.synchedLatestSlot.set(BigInt(lastBlock.header.getSlot())); } @@ -750,10 +755,21 @@ 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.isEpochPrune(newCheckpoint); + await this.txPool.handlePrunedBlocks(latestBlock, { deleteAllTxs }); + } + + /** Returns true if the prune spans an epoch boundary (checkpoint number changed). */ + private isEpochPrune(newCheckpoint: CheckpointId): boolean { + if (this.latestMinedCheckpointNumber <= 0) { + return false; + } + return this.latestMinedCheckpointNumber !== newCheckpoint.number; } /** 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(); } From 3e96287269acf13b63a5a6a7e8d619b2ab16aad4 Mon Sep 17 00:00:00 2001 From: Phil Windle Date: Tue, 17 Feb 2026 16:50:51 +0000 Subject: [PATCH 2/4] Use the tips store --- .../p2p/src/client/p2p_client.test.ts | 24 +++++++++++++++---- yarn-project/p2p/src/client/p2p_client.ts | 24 ++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/yarn-project/p2p/src/client/p2p_client.test.ts b/yarn-project/p2p/src/client/p2p_client.test.ts index 413cda74dc8e..ad52664a2533 100644 --- a/yarn-project/p2p/src/client/p2p_client.test.ts +++ b/yarn-project/p2p/src/client/p2p_client.test.ts @@ -275,18 +275,34 @@ describe('P2P Client', () => { }); describe('Chain prunes', () => { - it('calls handlePrunedBlocks when chain is pruned', async () => { + it('passes deleteAllTxs: false for a single-checkpoint prune', async () => { blockSource.setProvenBlockNumber(0); + blockSource.setCheckpointedBlockNumber(100); + await client.start(); + + // Prune 1 block: checkpoint goes from 100 to 99 (difference of 1) + blockSource.removeBlocks(1); + await client.sync(); + + expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith( + { number: BlockNumber(99), hash: expect.any(String) }, + { deleteAllTxs: false }, + ); + await client.stop(); + }); + + it('passes deleteAllTxs: true for an epoch prune spanning multiple checkpoints', async () => { + blockSource.setProvenBlockNumber(0); + blockSource.setCheckpointedBlockNumber(100); await client.start(); - // Prune the chain back to block 90 + // Prune 10 blocks: checkpoint goes from 100 to 90 (difference of 10) blockSource.removeBlocks(10); await client.sync(); - // Verify handlePrunedBlocks is called with the correct block ID and options expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith( { number: BlockNumber(90), hash: expect.any(String) }, - expect.objectContaining({ deleteAllTxs: expect.any(Boolean) }), + { deleteAllTxs: true }, ); await client.stop(); }); diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index 5d9c54d044e5..f4da11a4c3fc 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -89,9 +89,6 @@ export class P2PClient /** Tracks the last slot for which we called prepareForSlot */ private lastSlotProcessed: SlotNumber = SlotNumber.ZERO; - /** Tracks the checkpoint number of the latest mined block, used for epoch prune detection */ - private latestMinedCheckpointNumber: CheckpointNumber = CheckpointNumber.ZERO; - /** Polls for slot changes and calls prepareForSlot on the tx pool */ private slotMonitor: RunningPromise | undefined; @@ -704,7 +701,6 @@ export class P2PClient await this.maybeCallPrepareForSlot(); await this.startCollectingMissingTxs(blocks); const lastBlock = blocks.at(-1)!; - this.latestMinedCheckpointNumber = lastBlock.checkpointNumber; await this.synchedLatestSlot.set(BigInt(lastBlock.header.getSlot())); } @@ -760,16 +756,26 @@ export class P2PClient * @param newCheckpoint - The checkpoint ID after the prune. */ private async handlePruneL2Blocks(latestBlock: L2BlockId, newCheckpoint: CheckpointId): Promise { - const deleteAllTxs = this.isEpochPrune(newCheckpoint); + const deleteAllTxs = await this.isEpochPrune(newCheckpoint); await this.txPool.handlePrunedBlocks(latestBlock, { deleteAllTxs }); } - /** Returns true if the prune spans an epoch boundary (checkpoint number changed). */ - private isEpochPrune(newCheckpoint: CheckpointId): boolean { - if (this.latestMinedCheckpointNumber <= 0) { + /** + * Returns true if the prune spans multiple checkpoints (epoch prune). + * Compares the checkpointed tip from our local L2TipsStore (pre-prune) with the new checkpoint. + * Not entirely accurate: this could return false for the prune of an epoch with only one checkpoint. + */ + private async isEpochPrune(newCheckpoint: CheckpointId): Promise { + const tips = await this.l2Tips.getL2Tips(); + const oldCheckpointNumber = tips.checkpointed.checkpoint.number; + if (oldCheckpointNumber <= CheckpointNumber.ZERO) { return false; } - return this.latestMinedCheckpointNumber !== newCheckpoint.number; + const isEpochPrune = oldCheckpointNumber - newCheckpoint.number > 1; + 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. */ From ab981a20a89fbcbacd1c77038e67cd74df4a348d Mon Sep 17 00:00:00 2001 From: Phil Windle Date: Tue, 17 Feb 2026 16:55:45 +0000 Subject: [PATCH 3/4] Gate on config --- .../p2p/src/client/p2p_client.test.ts | 19 +++++++++++++++++++ yarn-project/p2p/src/client/p2p_client.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/yarn-project/p2p/src/client/p2p_client.test.ts b/yarn-project/p2p/src/client/p2p_client.test.ts index ad52664a2533..2abc1cf806e7 100644 --- a/yarn-project/p2p/src/client/p2p_client.test.ts +++ b/yarn-project/p2p/src/client/p2p_client.test.ts @@ -276,6 +276,7 @@ describe('P2P Client', () => { describe('Chain prunes', () => { it('passes deleteAllTxs: false for a single-checkpoint prune', async () => { + client = createClient({ txPoolDeleteTxsAfterReorg: true }); blockSource.setProvenBlockNumber(0); blockSource.setCheckpointedBlockNumber(100); await client.start(); @@ -292,6 +293,7 @@ describe('P2P Client', () => { }); it('passes deleteAllTxs: true for an epoch prune spanning multiple checkpoints', async () => { + client = createClient({ txPoolDeleteTxsAfterReorg: true }); blockSource.setProvenBlockNumber(0); blockSource.setCheckpointedBlockNumber(100); await client.start(); @@ -307,6 +309,23 @@ describe('P2P Client', () => { await client.stop(); }); + it('passes deleteAllTxs: false for epoch prune when txPoolDeleteTxsAfterReorg is disabled', async () => { + // Default config has txPoolDeleteTxsAfterReorg: false + blockSource.setProvenBlockNumber(0); + blockSource.setCheckpointedBlockNumber(100); + await client.start(); + + // Prune 10 blocks: would be epoch prune, but config flag is off + blockSource.removeBlocks(10); + await client.sync(); + + expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith( + { number: BlockNumber(90), hash: expect.any(String) }, + { deleteAllTxs: false }, + ); + await client.stop(); + }); + it('moves the tips on a chain reorg', async () => { blockSource.setProvenBlockNumber(0); // Set checkpointed before starting so blocks are synced as checkpointed diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index f4da11a4c3fc..aa0ed6c8d765 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -756,7 +756,7 @@ export class P2PClient * @param newCheckpoint - The checkpoint ID after the prune. */ private async handlePruneL2Blocks(latestBlock: L2BlockId, newCheckpoint: CheckpointId): Promise { - const deleteAllTxs = await this.isEpochPrune(newCheckpoint); + const deleteAllTxs = this.config.txPoolDeleteTxsAfterReorg && (await this.isEpochPrune(newCheckpoint)); await this.txPool.handlePrunedBlocks(latestBlock, { deleteAllTxs }); } From 623d2ced96811752ceb87c7437d01c19d9c8b92f Mon Sep 17 00:00:00 2001 From: Phil Windle Date: Tue, 17 Feb 2026 18:25:34 +0000 Subject: [PATCH 4/4] Just use checkpoint difference --- .../p2p/src/client/p2p_client.test.ts | 30 +++++++++++-------- yarn-project/p2p/src/client/p2p_client.ts | 8 ++--- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/yarn-project/p2p/src/client/p2p_client.test.ts b/yarn-project/p2p/src/client/p2p_client.test.ts index 2abc1cf806e7..aa670f6da934 100644 --- a/yarn-project/p2p/src/client/p2p_client.test.ts +++ b/yarn-project/p2p/src/client/p2p_client.test.ts @@ -275,52 +275,56 @@ describe('P2P Client', () => { }); describe('Chain prunes', () => { - it('passes deleteAllTxs: false for a single-checkpoint prune', async () => { + it('passes deleteAllTxs: false when prune does not cross a checkpoint boundary', async () => { client = createClient({ txPoolDeleteTxsAfterReorg: true }); blockSource.setProvenBlockNumber(0); - blockSource.setCheckpointedBlockNumber(100); + // Only checkpoint up to block 90 — blocks 91-100 are proposed but not checkpointed + blockSource.setCheckpointedBlockNumber(90); await client.start(); - // Prune 1 block: checkpoint goes from 100 to 99 (difference of 1) - blockSource.removeBlocks(1); + // Prune 5 blocks (91-100): checkpointed tip stays at checkpoint 90 + blockSource.removeBlocks(10); await client.sync(); expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith( - { number: BlockNumber(99), hash: expect.any(String) }, + { number: BlockNumber(90), hash: expect.any(String) }, { deleteAllTxs: false }, ); await client.stop(); }); - it('passes deleteAllTxs: true for an epoch prune spanning multiple checkpoints', async () => { + 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 10 blocks: checkpoint goes from 100 to 90 (difference of 10) - blockSource.removeBlocks(10); + // 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(90), hash: expect.any(String) }, + { number: BlockNumber(95), hash: expect.any(String) }, { deleteAllTxs: true }, ); await client.stop(); }); - it('passes deleteAllTxs: false for epoch prune when txPoolDeleteTxsAfterReorg is disabled', async () => { + 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 10 blocks: would be epoch prune, but config flag is off - blockSource.removeBlocks(10); + // 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(90), hash: expect.any(String) }, + { 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 aa0ed6c8d765..04191b75d937 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -761,9 +761,9 @@ export class P2PClient } /** - * Returns true if the prune spans multiple checkpoints (epoch prune). - * Compares the checkpointed tip from our local L2TipsStore (pre-prune) with the new checkpoint. - * Not entirely accurate: this could return false for the prune of an epoch with only one checkpoint. + * 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(); @@ -771,7 +771,7 @@ export class P2PClient if (oldCheckpointNumber <= CheckpointNumber.ZERO) { return false; } - const isEpochPrune = oldCheckpointNumber - newCheckpoint.number > 1; + const isEpochPrune = oldCheckpointNumber !== newCheckpoint.number; this.log.info( `Detected epoch prune: ${isEpochPrune}. Old checkpoint: ${oldCheckpointNumber}, new checkpoint: ${newCheckpoint.number}`, );