From a21a629ae4c40bafc74df7cae10d8f5a6e617eaa Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig Date: Tue, 19 May 2026 09:01:55 +0200 Subject: [PATCH 1/2] Fix mempool internal book keeping for prune notes. --- .../block-producer/src/domain/transaction.rs | 9 +++- .../block-producer/src/mempool/graph/dag.rs | 6 ++- .../src/mempool/graph/transaction.rs | 19 +++++++ crates/block-producer/src/mempool/tests.rs | 50 ++++++++++++++++++- 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/crates/block-producer/src/domain/transaction.rs b/crates/block-producer/src/domain/transaction.rs index e9580f3c72..d953d6b489 100644 --- a/crates/block-producer/src/domain/transaction.rs +++ b/crates/block-producer/src/domain/transaction.rs @@ -26,8 +26,9 @@ pub struct AuthenticatedTransaction { /// This does not necessarily have to match the transaction's initial state /// as this may still be modified by inflight transactions. store_account_state: Option, - /// Unauthenticated note commitments that have now been authenticated by the store - /// [inputs](TransactionInputs). + /// Unauthenticated note commitments that have now been authenticated by committed state, + /// either through store [inputs](TransactionInputs) or through locally committed mempool + /// history. /// /// In other words, notes which were unauthenticated at the time the transaction was proven, /// but which have since been committed to, and authenticated by the store. @@ -121,6 +122,10 @@ impl AuthenticatedTransaction { .filter(|commitment| !self.notes_authenticated_by_store.contains(commitment)) } + pub(crate) fn mark_notes_authenticated(&mut self, notes: impl IntoIterator) { + self.notes_authenticated_by_store.extend(notes); + } + pub fn proven_transaction(&self) -> Arc { Arc::clone(&self.inner) } diff --git a/crates/block-producer/src/mempool/graph/dag.rs b/crates/block-producer/src/mempool/graph/dag.rs index b1277ceb0b..b975b44d50 100644 --- a/crates/block-producer/src/mempool/graph/dag.rs +++ b/crates/block-producer/src/mempool/graph/dag.rs @@ -148,7 +148,7 @@ where /// Returns the node and its descendants. /// /// That is, this returns the node's children, their children etc. - fn descendants(&self, node: &N::Id) -> HashSet { + pub(super) fn descendants(&self, node: &N::Id) -> HashSet { let mut to_process = vec![*node]; let mut descendants = HashSet::default(); @@ -274,6 +274,10 @@ where pub fn contains(&self, node: &N::Id) -> bool { self.nodes.contains_key(node) } + + pub(super) fn get_mut(&mut self, node: &N::Id) -> Option<&mut N> { + self.nodes.get_mut(node) + } } // GRAPH DAG TESTS diff --git a/crates/block-producer/src/mempool/graph/transaction.rs b/crates/block-producer/src/mempool/graph/transaction.rs index 219e87a372..f7f1fec9de 100644 --- a/crates/block-producer/src/mempool/graph/transaction.rs +++ b/crates/block-producer/src/mempool/graph/transaction.rs @@ -320,6 +320,7 @@ impl TransactionGraph { /// graph. pub fn prune(&mut self, batch: &SelectedBatch) { for tx in batch.transactions() { + self.mark_committed_notes_authenticated_for_descendants(tx); self.inner.prune(tx.id()); self.failures.remove(&tx.id()); self.txs_user_batch.remove(&tx.id()); @@ -327,6 +328,24 @@ impl TransactionGraph { self.user_batch_txs.remove(&batch.id()); } + fn mark_committed_notes_authenticated_for_descendants( + &mut self, + tx: &Arc, + ) { + let output_notes = tx.output_note_commitments().collect::>(); + if output_notes.is_empty() { + return; + } + + let tx_id = tx.id(); + let descendants = self.inner.descendants(&tx_id); + for descendant in descendants.into_iter().filter(|descendant| *descendant != tx_id) { + if let Some(descendant) = self.inner.get_mut(&descendant) { + Arc::make_mut(descendant).mark_notes_authenticated(output_notes.iter().copied()); + } + } + } + /// Number of transactions which have not been selected for inclusion in a batch. pub fn unselected_count(&self) -> usize { self.inner.node_count() - self.inner.selected_count() diff --git a/crates/block-producer/src/mempool/tests.rs b/crates/block-producer/src/mempool/tests.rs index 946891207d..d52f76c9db 100644 --- a/crates/block-producer/src/mempool/tests.rs +++ b/crates/block-producer/src/mempool/tests.rs @@ -7,8 +7,8 @@ use serial_test::serial; use super::*; use crate::mempool::graph::TransactionGraph; -use crate::test_utils::MockProvenTxBuilder; use crate::test_utils::batch::TransactionBatchConstructor; +use crate::test_utils::{MockProvenTxBuilder, mock_account_id}; mod add_transaction; mod add_user_batch; @@ -178,6 +178,54 @@ fn empty_block_commitment() { } } +#[test] +fn pruned_committed_notes_are_authenticated_for_inflight_descendants() { + let (mut uut, _) = Mempool::for_tests(); + uut.config.state_retention = NonZeroUsize::new(1).unwrap(); + + let parent = MockProvenTxBuilder::with_account( + mock_account_id(1), + Word::empty(), + Word::new([1u32.into(), 1u32.into(), 2u32.into(), 3u32.into()]), + ) + .private_notes_created_range(3..4) + .build(); + let parent = Arc::new(AuthenticatedTransaction::from_inner(parent)); + + let child = MockProvenTxBuilder::with_account( + mock_account_id(2), + Word::empty(), + Word::new([2u32.into(), 1u32.into(), 2u32.into(), 3u32.into()]), + ) + .unauthenticated_notes_range(3..4) + .build(); + let child = Arc::new(AuthenticatedTransaction::from_inner(child)); + + uut.add_transaction(parent.clone()).unwrap(); + let parent_batch = uut.select_batch().unwrap(); + assert_eq!(parent_batch.transactions(), std::slice::from_ref(&parent)); + + uut.add_transaction(child.clone()).unwrap(); + uut.commit_batch(Arc::new(ProvenBatch::mocked_from_transactions([ + parent.raw_proven_transaction() + ]))); + + let block = uut.select_block(); + let header = BlockHeader::mock(block.block_number, None, None, &[], Word::empty()); + uut.commit_block(header); + + let block = uut.select_block(); + let header = BlockHeader::mock(block.block_number, None, None, &[], Word::empty()); + uut.commit_block(header); + + let child_batch = uut.select_batch().unwrap(); + + assert_eq!(child_batch.transactions().len(), 1); + assert_eq!(child_batch.transactions()[0].id(), child.id()); + assert_eq!(child_batch.transactions()[0].unauthenticated_note_commitments().count(), 0); + assert_eq!(child_batch.unauthenticated_note_commitments().count(), 0); +} + #[test] #[should_panic] fn block_commitment_is_rejected_if_no_block_is_in_flight() { From bfabd88edefe549a788b6d51a9d3574646c1b1d2 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig Date: Tue, 19 May 2026 09:13:42 +0200 Subject: [PATCH 2/2] Changelog --- CHANGELOG.md | 1 + crates/block-producer/src/mempool/tests.rs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a53aa2aa4..1a459a89d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - [BREAKING] Removed `miden-node ntx-builder` subcommand and created a separate `miden-ntx-builder` binary ([#2067](https://github.com/0xMiden/node/pull/2067)). - [BREAKING] Reworked note proto types for multi-attachment support: `NoteMetadata` now carries `attachment_schemes` (repeated) and `attachments_commitment` instead of a single `attachment`. `Note` and `NetworkNote` gained an `attachments` field. `NoteSyncRecord` now embeds full `NoteMetadata` instead of `NoteMetadataHeader`. Removed `NoteAttachmentKind` enum and `NoteMetadataHeader` message ([#2078](https://github.com/0xMiden/node/pull/2078)). - [BREAKING] Changed `SyncChainMmr` endpoint: the upper end of the block range we're syncing is now the chain tip with the requested finality level. Validator signature is also returned ([#2075](https://github.com/0xMiden/node/pull/2075)). +- Fixed block producer mempool panic when selecting transactions that depend on notes created by pruned committed transactions ([#2097](https://github.com/0xMiden/node/pull/2097)). ## v0.14.10 (2026-05-29) diff --git a/crates/block-producer/src/mempool/tests.rs b/crates/block-producer/src/mempool/tests.rs index d52f76c9db..e2fcbe3c79 100644 --- a/crates/block-producer/src/mempool/tests.rs +++ b/crates/block-producer/src/mempool/tests.rs @@ -178,6 +178,13 @@ fn empty_block_commitment() { } } +/// Regression test for a child transaction that consumes an unauthenticated note produced by a +/// parent transaction which has already been committed and later pruned from retained mempool +/// history. +/// +/// The child remains in the transaction graph after the parent block is committed. Once retention +/// pruning removes the parent, the note is no longer represented by an inflight transaction, so the +/// child must stop reporting it as unauthenticated before it is selected into its own batch. #[test] fn pruned_committed_notes_are_authenticated_for_inflight_descendants() { let (mut uut, _) = Mempool::for_tests();