From 69b73865568d7ffe6b55ab74ecd9b3a5c2249603 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Mon, 29 Jun 2026 15:46:14 -0300 Subject: [PATCH 1/8] feat(rust-client): recover erased and externally-consumed notes for InputNoteReader Preserve the note header on unauthenticated input commitments so a follower that imports an account can surface notes it only learns about from the consumer's transactions, including same-batch-erased notes that never reach a block. Add the ConsumedExternalErased state for header-only records, and retain note metadata in ConsumedExternal so externally-consumed committed notes keep a recoverable note id. The Consumed note filter and InputNoteReader return both kinds in consumption order. --- bin/integration-tests/src/tests/onchain.rs | 138 +++++++++++++++++- .../src/note/note_update_tracker.rs | 40 ++++- .../rust-client/src/rpc/domain/transaction.rs | 10 +- .../note_record/input_note_record/mod.rs | 68 ++++++++- .../input_note_record/states/committed.rs | 1 + .../states/consumed_external.rs | 16 +- .../states/consumed_external_erased.rs | 125 ++++++++++++++++ .../input_note_record/states/expected.rs | 1 + .../input_note_record/states/invalid.rs | 1 + .../input_note_record/states/mod.rs | 26 ++++ .../states/processing_authenticated.rs | 1 + .../states/processing_unauthenticated.rs | 1 + .../input_note_record/states/unverified.rs | 1 + crates/rust-client/src/sync/state_sync.rs | 23 +++ crates/sqlite-store/src/note/filters.rs | 5 +- crates/sqlite-store/src/note/mod.rs | 1 + crates/sqlite-store/src/note/tests.rs | 92 +++++++++++- .../testing/miden-client-tests/src/tests.rs | 8 +- 18 files changed, 540 insertions(+), 18 deletions(-) create mode 100644 crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs diff --git a/bin/integration-tests/src/tests/onchain.rs b/bin/integration-tests/src/tests/onchain.rs index 65f6049869..634102b321 100644 --- a/bin/integration-tests/src/tests/onchain.rs +++ b/bin/integration-tests/src/tests/onchain.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use anyhow::{Context, Result}; -use miden_client::account::{AccountType, build_wallet_id}; +use miden_client::account::{AccountId, AccountType, build_wallet_id}; use miden_client::asset::{Asset, FungibleAsset}; use miden_client::auth::RPO_FALCON_SCHEME_ID; use miden_client::keystore::Keystore; @@ -11,6 +11,7 @@ use miden_client::note::{ NoteAttachmentScheme, NoteAttachments, NoteFile, + NoteId, NoteType, P2idNote, }; @@ -716,6 +717,141 @@ pub async fn test_consumed_note_ordering(client_config: ClientConfig) -> Result< Ok(()) } +/// Two-client proof that a pure importer (follower) of an account reads BOTH erased and committed +/// notes the account consumed, intercalated in consumption order. +/// +/// Client A owns the faucet and the consumer and drives all activity; client B only +/// `import_account_by_id`s the consumer. Across increasing blocks the consumer consumes: an erased +/// note (minted and consumed in the same batch, so it never lands in a block), then a committed +/// note, then another erased note. B syncs and its `InputNoteReader` returns all three in order: +/// the erased ones surface as header-only records (their headers ride the consumer's transaction as +/// unauthenticated input commitments), the committed one in full. This exercises the consumer-side +/// header path end to end, and confirms the node populates the header for unauthenticated inputs. +pub async fn test_importer_note_reader_finds_erased_and_committed_interleaved( + client_config: ClientConfig, +) -> Result<()> { + let (mut client_a, keystore_a) = client_config.clone().into_client().await?; + let (mut client_b, _keystore_b) = ClientConfig::default() + .with_rpc_endpoint(client_config.rpc_endpoint()) + .into_client() + .await?; + wait_for_node(&mut client_a).await; + + let (faucet, _) = insert_new_fungible_faucet( + &mut client_a, + AccountType::Public, + &keystore_a, + RPO_FALCON_SCHEME_ID, + ) + .await?; + let (consumer, ..) = + insert_new_wallet(&mut client_a, AccountType::Public, &keystore_a, RPO_FALCON_SCHEME_ID) + .await?; + let consumer_id = consumer.id(); + let faucet_id = faucet.id(); + client_a.sync_state().await?; + + // Put the consumer on-chain and let client B import it (registering its note tag). + let bootstrap_tx = + mint_and_consume(&mut client_a, consumer_id, faucet_id, NoteType::Public).await; + wait_for_tx(&mut client_a, bootstrap_tx).await?; + client_a.sync_state().await?; + client_b.import_account_by_id(consumer_id).await?; + client_b.sync_state().await?; + + // Consumption order is recorded here as each note is consumed in its own (increasing) block. + let mut expected_ids = Vec::new(); + + // (1) ERASED: minted to and consumed by the consumer in the same batch. + expected_ids.push(mint_and_consume_erased_note(&mut client_a, faucet_id, consumer_id).await?); + + // (2) COMMITTED: minted to the consumer and committed; B must see it while still unspent to + // capture its full details, then the consumer consumes it. + let (mint_tx, committed_note) = + mint_note(&mut client_a, consumer_id, faucet_id, NoteType::Public).await; + wait_for_tx(&mut client_a, mint_tx).await?; + let committed_commitment = committed_note.details_commitment(); + for _ in 0..10 { + client_b.sync_state().await?; + let tracked = client_b.get_input_notes(NoteFilter::All).await?; + if tracked.iter().any(|n| n.details_commitment() == committed_commitment) { + break; + } + wait_for_blocks(&mut client_b, 1).await; + } + let consume_tx = + consume_notes(&mut client_a, consumer_id, std::slice::from_ref(&committed_note)).await; + wait_for_tx(&mut client_a, consume_tx).await?; + expected_ids.push(committed_note.id()); + + // (3) ERASED again. + expected_ids.push(mint_and_consume_erased_note(&mut client_a, faucet_id, consumer_id).await?); + + // B syncs until its reader surfaces all three of our notes, then we check their order. + let mut ordered = Vec::new(); + for _ in 0..20 { + client_b.sync_state().await?; + let mut reader = client_b.input_note_reader(consumer_id); + ordered.clear(); + while let Some(note) = reader.next().await? { + if note.id().is_some_and(|id| expected_ids.contains(&id)) { + ordered.push(note); + } + } + if ordered.len() == expected_ids.len() { + break; + } + wait_for_blocks(&mut client_b, 1).await; + } + + let ordered_ids: Vec<_> = ordered.iter().filter_map(|n| n.id()).collect(); + assert_eq!( + ordered_ids, expected_ids, + "follower's reader should return erased and committed notes intercalated in consumption order", + ); + + // The erased notes are header-only; the committed note carries full details. + assert!(!ordered[0].has_details(), "first note (erased) should be header-only"); + assert!(ordered[1].has_details(), "second note (committed) should carry full details"); + assert!(!ordered[2].has_details(), "third note (erased) should be header-only"); + for note in &ordered { + assert_eq!(note.consumer_account(), Some(consumer_id)); + } + + Ok(()) +} + +/// Mints a note to `consumer_id` and consumes it unauthenticated in the same batch, so the note is +/// erased (never committed to a block). Returns the erased note's id. Waits for the batch to land +/// in a block so callers can sequence consumptions into distinct, increasing blocks. +async fn mint_and_consume_erased_note( + client: &mut TestClient, + faucet_id: AccountId, + consumer_id: AccountId, +) -> Result { + let mint_request = TransactionRequestBuilder::new().build_mint_fungible_asset( + FungibleAsset::new(faucet_id, 100).unwrap(), + consumer_id, + NoteType::Public, + client.rng(), + )?; + let erased_note = mint_request + .expected_output_own_notes() + .pop() + .context("mint request should produce exactly one output note")?; + let note_id = erased_note.id(); + let consume_request = + TransactionRequestBuilder::new().build_consume_notes(vec![erased_note])?; + + let mut batch = client.new_transaction_batch(); + batch = batch.push(faucet_id, mint_request).await?; + batch = batch.push(consumer_id, consume_request).await?; + batch.submit().await?; + wait_for_blocks(client, 2).await; + + Ok(note_id) +} + /// Verifies syncing and consuming notes with attachments, for both a public and a private note. /// 1. Client 1 mints a public and a private P2ID note, each with an attachment, targeting client 2. /// 2. Client 2 syncs and discovers both notes via `sync_notes`. diff --git a/crates/rust-client/src/note/note_update_tracker.rs b/crates/rust-client/src/note/note_update_tracker.rs index 84adc3413c..ddfabd1a92 100644 --- a/crates/rust-client/src/note/note_update_tracker.rs +++ b/crates/rust-client/src/note/note_update_tracker.rs @@ -12,6 +12,7 @@ use miden_protocol::note::{ NoteMetadata, Nullifier, }; +use miden_protocol::transaction::InputNoteCommitment; use miden_standards::note::NetworkAccountTarget; use miden_tx::utils::serde::{ ByteReader, @@ -318,9 +319,10 @@ impl NoteUpdateTracker { .filter(|note| note.update_type.is_modified()) } - /// Returns the ids of updated input notes that are now consumed, by tracking key. Consumed - /// states carry no metadata, so `InputNoteRecord::id` is `None`; the key (the id assigned at - /// commit) is used instead. + /// Returns the ids of updated input notes that are now consumed, by tracking key. The key (the + /// id assigned when the record was inserted) is used rather than `InputNoteRecord::id`, which + /// can be `None` for a consumed record whose state carries no metadata (e.g. an + /// externally-consumed note imported from bare `NoteFile::NoteDetails`). pub fn consumed_input_note_ids(&self) -> impl Iterator + '_ { self.input_notes .iter() @@ -512,6 +514,38 @@ impl NoteUpdateTracker { Ok(()) } + /// Records a note consumed by a tracked account that the client only learns about from the + /// consuming transaction's input commitment. + /// + /// Unauthenticated input notes (which includes same-batch-erased notes) carry their full + /// [`NoteHeader`] in the transaction. When the client holds neither an input nor an output + /// record for such a note (i.e. it is following the consuming account rather than having + /// created the note), this builds a header-only record attributed to the `consumer` so the + /// consumption surfaces through [`crate::note::InputNoteReader`]. No-op when the commitment + /// carries no header (authenticated notes, discoverable via the note tag instead) or the note + /// is already tracked (the full-detail paths own that case). + pub(crate) fn insert_consumed_unauthenticated_note( + &mut self, + commitment: &InputNoteCommitment, + consumer: AccountId, + block_num: BlockNumber, + ) { + let Some(header) = commitment.header() else { + return; + }; + let note_id = header.id(); + if self.input_notes.contains_key(¬e_id) || self.output_notes.contains_key(¬e_id) { + return; + } + + // Preserve the note's position within the block when known; fall back to 0 so the record + // still satisfies the reader's "has a consumption order" requirement. + let order = self.get_nullifier_order(commitment.nullifier()).or(Some(0)); + let mut record = InputNoteRecord::from_header(header, block_num, Some(consumer)); + record.set_consumed_tx_order(order); + self.insert_input_note(record, NoteUpdateType::Insert); + } + /// Builds a consumed input note record from a tracked output note and inserts it. /// /// Used when an output note is consumed externally and the client should also surface diff --git a/crates/rust-client/src/rpc/domain/transaction.rs b/crates/rust-client/src/rpc/domain/transaction.rs index a97cc3b069..b82134e877 100644 --- a/crates/rust-client/src/rpc/domain/transaction.rs +++ b/crates/rust-client/src/rpc/domain/transaction.rs @@ -135,7 +135,15 @@ fn convert_transaction_header( .ok_or(RpcError::ExpectedDataMissing("nullifier".into()))? .try_into() .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?; - Ok(InputNoteCommitment::from(Nullifier::from_raw(word))) + // Unauthenticated input notes (which includes same-batch-erased notes) carry their + // full header here; preserve it so the consuming account's sync can surface the note + // even when the client never held its details. + let header = d + .header + .map(NoteHeader::try_from) + .transpose() + .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?; + Ok(InputNoteCommitment::from_parts_unchecked(Nullifier::from_raw(word), header)) }) .collect::, RpcError>>()?; let input_notes = InputNotes::new_unchecked(note_commitments); diff --git a/crates/rust-client/src/store/note_record/input_note_record/mod.rs b/crates/rust-client/src/store/note_record/input_note_record/mod.rs index 3680753216..e711f063d4 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/mod.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/mod.rs @@ -1,4 +1,5 @@ use alloc::string::ToString; +use alloc::vec; use miden_protocol::Word; use miden_protocol::account::AccountId; @@ -9,9 +10,12 @@ use miden_protocol::note::{ NoteAttachments, NoteDetails, NoteDetailsCommitment, + NoteHeader, NoteId, NoteInclusionProof, NoteMetadata, + NoteRecipient, + NoteStorage, Nullifier, }; use miden_protocol::transaction::{InputNote, TransactionId}; @@ -22,6 +26,7 @@ use miden_protocol::utils::serde::{ DeserializationError, Serializable, }; +use miden_standards::note::P2idNote; use super::NoteRecordError; @@ -29,6 +34,7 @@ mod states; pub use states::{ CommittedNoteState, ConsumedAuthenticatedLocalNoteState, + ConsumedExternalErasedNoteState, ConsumedExternalNoteState, ConsumedUnauthenticatedLocalNoteState, ExpectedNoteState, @@ -40,6 +46,19 @@ pub use states::{ UnverifiedNoteState, }; +/// Builds placeholder [`NoteDetails`] for a header-only record (see +/// [`InputNoteRecord::from_header`]). The bytes are meaningless and must not be inspected +/// ([`InputNoteRecord::has_details`] returns `false` for these records); the authoritative note id +/// and metadata live in the [`InputNoteState::ConsumedExternalErased`] state. The note id is folded +/// into the placeholder's serial number so the resulting details commitment, which is the store's +/// primary key for input notes, stays unique per note. +fn placeholder_details(note_id: NoteId) -> NoteDetails { + let assets = NoteAssets::new(vec![]).expect("empty assets are valid"); + let storage = NoteStorage::new(vec![]).expect("empty storage is valid"); + let recipient = NoteRecipient::new(note_id.as_word(), P2idNote::script(), storage); + NoteDetails::new(assets, recipient) +} + // INPUT NOTE RECORD // ================================================================================================ @@ -79,19 +98,56 @@ impl InputNoteRecord { InputNoteRecord { details, attachments, created_at, state } } + /// Creates a header-only record for an erased note (created and consumed in the same batch) + /// that the client only knows via its [`NoteHeader`]. The record is placed in the + /// [`InputNoteState::ConsumedExternalErased`] state, which carries the authoritative note id + /// and metadata; the `details` field is a placeholder (see [`placeholder_details`]) and + /// [`InputNoteRecord::has_details`] returns `false`. + pub fn from_header( + header: &NoteHeader, + nullifier_block_height: BlockNumber, + consumer_account: Option, + ) -> InputNoteRecord { + let state = ConsumedExternalErasedNoteState { + note_id: header.id(), + metadata: *header.metadata(), + nullifier_block_height, + consumer_account, + consumed_tx_order: None, + }; + InputNoteRecord { + details: placeholder_details(header.id()), + attachments: NoteAttachments::default(), + created_at: None, + state: state.into(), + } + } + // PUBLIC ACCESSORS // ================================================================================================ - /// Returns the input note ID, computed by combining the details commitment with the - /// note metadata. Returns `None` when the current state has no metadata (e.g. an - /// expected note imported from bare `NoteFile::NoteDetails`, or a note in the - /// `ConsumedExternal` state). Use [`Self::details_commitment`] when a stable identifier - /// is needed in those cases. + /// Returns the input note ID. For header-only records (state + /// [`InputNoteState::ConsumedExternalErased`]) this is read from the state; otherwise it is + /// computed by combining the details commitment with the note metadata, returning `None` when + /// the current state has no metadata (e.g. an expected note imported from bare + /// `NoteFile::NoteDetails`, or a `ConsumedExternal` note whose prior state carried no + /// metadata). Use [`Self::details_commitment`] when a stable identifier is needed in those + /// cases. pub fn id(&self) -> Option { + if let InputNoteState::ConsumedExternalErased(s) = &self.state { + return Some(s.note_id); + } let metadata = self.metadata()?; Some(NoteId::new(self.details.commitment(), metadata)) } + /// Returns `true` when the record carries authoritative note details. Returns `false` for + /// header-only records (built via [`InputNoteRecord::from_header`]), whose `details` field is + /// a placeholder. + pub fn has_details(&self) -> bool { + !matches!(self.state, InputNoteState::ConsumedExternalErased(_)) + } + /// Returns the commitment to the note's details (recipient + assets), independent of /// note metadata. pub fn details_commitment(&self) -> NoteDetailsCommitment { @@ -184,6 +240,7 @@ impl InputNoteRecord { Some(s.submission_data.consumer_account) }, InputNoteState::ConsumedExternal(s) => s.consumer_account, + InputNoteState::ConsumedExternalErased(s) => s.consumer_account, _ => None, } } @@ -204,6 +261,7 @@ impl InputNoteRecord { matches!( self.state, InputNoteState::ConsumedExternal { .. } + | InputNoteState::ConsumedExternalErased { .. } | InputNoteState::ConsumedAuthenticatedLocal { .. } | InputNoteState::ConsumedUnauthenticatedLocal { .. } ) diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/committed.rs b/crates/rust-client/src/store/note_record/input_note_record/states/committed.rs index 05f8a6e07e..c60faae48a 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/committed.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/committed.rs @@ -51,6 +51,7 @@ impl NoteStateHandler for CommittedNoteState { nullifier_block_height, consumer_account, consumed_tx_order: None, + metadata: Some(self.metadata), } .into(), )) diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external.rs b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external.rs index 788706b1f8..213709bfc8 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external.rs @@ -21,6 +21,11 @@ pub struct ConsumedExternalNoteState { /// Per-account position of the consuming transaction within the account's execution chain /// for the block. `None` if the order has not been determined yet. pub consumed_tx_order: Option, + /// Metadata associated with the note (sender, note type, tag and other additional + /// information), retained through consumption so the note ID stays recoverable. `None` + /// when the prior state had no metadata (e.g. a note imported from bare + /// `NoteFile::NoteDetails`). + pub metadata: Option, } impl NoteStateHandler for ConsumedExternalNoteState { @@ -68,7 +73,7 @@ impl NoteStateHandler for ConsumedExternalNoteState { } fn metadata(&self) -> Option<&NoteMetadata> { - None + self.metadata.as_ref() } fn inclusion_proof(&self) -> Option<&NoteInclusionProof> { @@ -85,6 +90,7 @@ impl miden_tx::utils::serde::Serializable for ConsumedExternalNoteState { self.nullifier_block_height.write_into(target); self.consumer_account.write_into(target); self.consumed_tx_order.write_into(target); + self.metadata.write_into(target); } } @@ -95,10 +101,18 @@ impl miden_tx::utils::serde::Deserializable for ConsumedExternalNoteState { let nullifier_block_height = BlockNumber::read_from(source)?; let consumer_account = Option::::read_from(source)?; let consumed_tx_order = Option::::read_from(source)?; + // Older persisted records have no metadata tail; when no bytes remain, decode it as `None`. + // The state owns its store column, so any remaining bytes belong to this field. + let metadata = if source.has_more_bytes() { + Option::::read_from(source)? + } else { + None + }; Ok(ConsumedExternalNoteState { nullifier_block_height, consumer_account, consumed_tx_order, + metadata, }) } } diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs new file mode 100644 index 0000000000..1ae1f6971c --- /dev/null +++ b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs @@ -0,0 +1,125 @@ +use alloc::string::ToString; + +use miden_protocol::account::AccountId; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::note::{NoteId, NoteInclusionProof, NoteMetadata}; +use miden_protocol::transaction::TransactionId; + +use super::{InputNoteState, NoteStateHandler}; +use crate::store::NoteRecordError; + +/// Information related to notes in the [`InputNoteState::ConsumedExternalErased`] state. +/// +/// A record enters this state when the client learns about an erased note (created and consumed in +/// the same batch) whose full details it never held, only the note header carried in the consuming +/// transaction. With no authoritative [`miden_protocol::note::NoteDetails`], the note id cannot be +/// derived the usual way (from the details commitment and metadata), so it is carried in the state +/// directly and surfaced through [`crate::note::InputNoteReader`]. +#[derive(Clone, Debug, PartialEq)] +pub struct ConsumedExternalErasedNoteState { + /// The note id, stored directly. Unlike full records, it cannot be derived from the record's + /// details, which are a placeholder for this state. + pub note_id: NoteId, + /// Metadata associated with the note, including sender, note type, tag and other additional + /// information. + pub metadata: NoteMetadata, + /// Block height at which the note was nullified. + pub nullifier_block_height: BlockNumber, + /// The account that consumed the note, if it is tracked by this client. + pub consumer_account: Option, + /// Per-account position of the consuming transaction within the account's execution chain + /// for the block. `None` if the order has not been determined yet. + pub consumed_tx_order: Option, +} + +impl NoteStateHandler for ConsumedExternalErasedNoteState { + fn inclusion_proof_received( + &self, + _inclusion_proof: NoteInclusionProof, + _metadata: NoteMetadata, + ) -> Result, NoteRecordError> { + Ok(None) + } + + fn consumed_externally( + &self, + _nullifier_block_height: BlockNumber, + _consumer_account: Option, + ) -> Result, NoteRecordError> { + Ok(None) + } + + fn block_header_received( + &self, + _note_id: NoteId, + _block_header: &BlockHeader, + ) -> Result, NoteRecordError> { + Ok(None) + } + + fn consumed_locally( + &self, + _consumer_account: AccountId, + _consumer_transaction: TransactionId, + _current_timestamp: Option, + ) -> Result, NoteRecordError> { + Err(NoteRecordError::NoteNotConsumable("Note already consumed".to_string())) + } + + fn transaction_committed( + &self, + _transaction_id: TransactionId, + _block_height: BlockNumber, + ) -> Result, NoteRecordError> { + Err(NoteRecordError::InvalidStateTransition( + "Only processing notes can be committed in a local transaction".to_string(), + )) + } + + fn metadata(&self) -> Option<&NoteMetadata> { + Some(&self.metadata) + } + + fn inclusion_proof(&self) -> Option<&NoteInclusionProof> { + None + } + + fn consumer_transaction_id(&self) -> Option<&TransactionId> { + None + } +} + +impl miden_tx::utils::serde::Serializable for ConsumedExternalErasedNoteState { + fn write_into(&self, target: &mut W) { + self.note_id.write_into(target); + self.metadata.write_into(target); + self.nullifier_block_height.write_into(target); + self.consumer_account.write_into(target); + self.consumed_tx_order.write_into(target); + } +} + +impl miden_tx::utils::serde::Deserializable for ConsumedExternalErasedNoteState { + fn read_from( + source: &mut R, + ) -> Result { + let note_id = NoteId::read_from(source)?; + let metadata = NoteMetadata::read_from(source)?; + let nullifier_block_height = BlockNumber::read_from(source)?; + let consumer_account = Option::::read_from(source)?; + let consumed_tx_order = Option::::read_from(source)?; + Ok(ConsumedExternalErasedNoteState { + note_id, + metadata, + nullifier_block_height, + consumer_account, + consumed_tx_order, + }) + } +} + +impl From for InputNoteState { + fn from(state: ConsumedExternalErasedNoteState) -> Self { + InputNoteState::ConsumedExternalErased(state) + } +} diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/expected.rs b/crates/rust-client/src/store/note_record/input_note_record/states/expected.rs index 7cc131335a..d1f7ecbf42 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/expected.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/expected.rs @@ -48,6 +48,7 @@ impl NoteStateHandler for ExpectedNoteState { nullifier_block_height, consumer_account, consumed_tx_order: None, + metadata: self.metadata, } .into(), )) diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/invalid.rs b/crates/rust-client/src/store/note_record/input_note_record/states/invalid.rs index 80df176777..3e4448f0e6 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/invalid.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/invalid.rs @@ -46,6 +46,7 @@ impl NoteStateHandler for InvalidNoteState { nullifier_block_height, consumer_account, consumed_tx_order: None, + metadata: Some(self.metadata), } .into(), )) diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/mod.rs b/crates/rust-client/src/store/note_record/input_note_record/states/mod.rs index 6f87162edc..d2f4c8432a 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/mod.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/mod.rs @@ -17,6 +17,7 @@ pub use miden_tx::utils::serde::{ mod committed; mod consumed_authenticated_local; mod consumed_external; +mod consumed_external_erased; mod consumed_unauthenticated_local; mod expected; mod invalid; @@ -27,6 +28,7 @@ mod unverified; pub use committed::CommittedNoteState; pub use consumed_authenticated_local::ConsumedAuthenticatedLocalNoteState; pub use consumed_external::ConsumedExternalNoteState; +pub use consumed_external_erased::ConsumedExternalErasedNoteState; pub use consumed_unauthenticated_local::ConsumedUnauthenticatedLocalNoteState; pub use expected::ExpectedNoteState; pub use invalid::InvalidNoteState; @@ -58,6 +60,9 @@ pub enum InputNoteState { ConsumedUnauthenticatedLocal(ConsumedUnauthenticatedLocalNoteState), /// Note consumed by a transaction not submitted by this client and confirmed by the network. ConsumedExternal(ConsumedExternalNoteState), + /// Note discovered as erased (created and consumed within the same batch) for which the + /// client never held the full details, only the header from the consuming transaction. + ConsumedExternalErased(ConsumedExternalErasedNoteState), } impl InputNoteState { @@ -70,6 +75,7 @@ impl InputNoteState { pub const STATE_CONSUMED_AUTHENTICATED_LOCAL: u8 = 6; pub const STATE_CONSUMED_UNAUTHENTICATED_LOCAL: u8 = 7; pub const STATE_CONSUMED_EXTERNAL: u8 = 8; + pub const STATE_CONSUMED_EXTERNAL_ERASED: u8 = 9; /// Returns the inner state handler that implements state transitions. fn inner(&self) -> &dyn NoteStateHandler { @@ -83,6 +89,7 @@ impl InputNoteState { InputNoteState::ConsumedAuthenticatedLocal(inner) => inner, InputNoteState::ConsumedUnauthenticatedLocal(inner) => inner, InputNoteState::ConsumedExternal(inner) => inner, + InputNoteState::ConsumedExternalErased(inner) => inner, } } @@ -102,6 +109,7 @@ impl InputNoteState { Self::STATE_CONSUMED_UNAUTHENTICATED_LOCAL }, InputNoteState::ConsumedExternal(_) => Self::STATE_CONSUMED_EXTERNAL, + InputNoteState::ConsumedExternalErased(_) => Self::STATE_CONSUMED_EXTERNAL_ERASED, } } @@ -123,6 +131,7 @@ impl InputNoteState { InputNoteState::ConsumedAuthenticatedLocal(s) => Some(s.nullifier_block_height), InputNoteState::ConsumedUnauthenticatedLocal(s) => Some(s.nullifier_block_height), InputNoteState::ConsumedExternal(s) => Some(s.nullifier_block_height), + InputNoteState::ConsumedExternalErased(s) => Some(s.nullifier_block_height), _ => None, } } @@ -134,6 +143,7 @@ impl InputNoteState { InputNoteState::ConsumedAuthenticatedLocal(s) => s.consumed_tx_order, InputNoteState::ConsumedUnauthenticatedLocal(s) => s.consumed_tx_order, InputNoteState::ConsumedExternal(s) => s.consumed_tx_order, + InputNoteState::ConsumedExternalErased(s) => s.consumed_tx_order, _ => None, } } @@ -145,6 +155,7 @@ impl InputNoteState { InputNoteState::ConsumedAuthenticatedLocal(s) => s.consumed_tx_order = order, InputNoteState::ConsumedUnauthenticatedLocal(s) => s.consumed_tx_order = order, InputNoteState::ConsumedExternal(s) => s.consumed_tx_order = order, + InputNoteState::ConsumedExternalErased(s) => s.consumed_tx_order = order, _ => {}, } } @@ -217,6 +228,7 @@ impl Serializable for InputNoteState { InputNoteState::ConsumedAuthenticatedLocal(inner) => inner.write_into(target), InputNoteState::ConsumedUnauthenticatedLocal(inner) => inner.write_into(target), InputNoteState::ConsumedExternal(inner) => inner.write_into(target), + InputNoteState::ConsumedExternalErased(inner) => inner.write_into(target), } } } @@ -244,6 +256,9 @@ impl Deserializable for InputNoteState { Self::STATE_CONSUMED_EXTERNAL => { Ok(ConsumedExternalNoteState::read_from(source)?.into()) }, + Self::STATE_CONSUMED_EXTERNAL_ERASED => { + Ok(ConsumedExternalErasedNoteState::read_from(source)?.into()) + }, _ => Err(DeserializationError::InvalidValue(format!( "Invalid NoteState discriminant: {discriminant}" ))), @@ -332,6 +347,17 @@ impl Display for InputNoteState { write!(f, "Consumed (at block {})", state.nullifier_block_height) } }, + InputNoteState::ConsumedExternalErased(state) => { + if let Some(account) = state.consumer_account { + write!( + f, + "Consumed erased (at block {} by tracked account {})", + state.nullifier_block_height, account + ) + } else { + write!(f, "Consumed erased (at block {})", state.nullifier_block_height) + } + }, } } } diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/processing_authenticated.rs b/crates/rust-client/src/store/note_record/input_note_record/states/processing_authenticated.rs index e7ca5ad976..9bb2c0ce2c 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/processing_authenticated.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/processing_authenticated.rs @@ -53,6 +53,7 @@ impl NoteStateHandler for ProcessingAuthenticatedNoteState { nullifier_block_height, consumer_account, consumed_tx_order: None, + metadata: Some(self.metadata), } .into(), )) diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/processing_unauthenticated.rs b/crates/rust-client/src/store/note_record/input_note_record/states/processing_unauthenticated.rs index 63f31cf919..03124836c5 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/processing_unauthenticated.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/processing_unauthenticated.rs @@ -45,6 +45,7 @@ impl NoteStateHandler for ProcessingUnauthenticatedNoteState { nullifier_block_height, consumer_account, consumed_tx_order: None, + metadata: Some(self.metadata), } .into(), )) diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/unverified.rs b/crates/rust-client/src/store/note_record/input_note_record/states/unverified.rs index c662e3d2b8..f5e0256c33 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/unverified.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/unverified.rs @@ -45,6 +45,7 @@ impl NoteStateHandler for UnverifiedNoteState { nullifier_block_height, consumer_account, consumed_tx_order: None, + metadata: Some(self.metadata), } .into(), )) diff --git a/crates/rust-client/src/sync/state_sync.rs b/crates/rust-client/src/sync/state_sync.rs index f0bfae3267..d5a91c9862 100644 --- a/crates/rust-client/src/sync/state_sync.rs +++ b/crates/rust-client/src/sync/state_sync.rs @@ -646,6 +646,11 @@ impl StateSync { // Detect output notes erased by same-batch note erasure. Self::mark_erased_notes_as_consumed(state_sync_update, transaction); + + // Surface notes consumed by this tracked account that the client only learns about from + // the consuming transaction's unauthenticated input commitments (e.g. same-batch-erased + // notes it never created or discovered). + Self::record_consumed_unauthenticated_notes(state_sync_update, transaction); } Ok(()) @@ -668,6 +673,24 @@ impl StateSync { } } + /// Records notes consumed by a tracked account that arrive only as unauthenticated input + /// commitments on the consuming transaction (which includes same-batch-erased notes). The + /// consumer is the account that executed the transaction; the note's header travels with the + /// commitment, so a follower can surface the consumption even without ever holding the note. + fn record_consumed_unauthenticated_notes( + state_sync_update: &mut StateSyncUpdate, + transaction: &RpcTransactionRecord, + ) { + let consumer = transaction.transaction_header.account_id(); + for commitment in transaction.transaction_header.input_notes().iter() { + state_sync_update.note_updates.insert_consumed_unauthenticated_note( + commitment, + consumer, + transaction.block_num, + ); + } + } + /// Compares the state of tracked accounts with the updates received from the node. The method /// Updates the `account_updates` with the details of the accounts that need to be updated. /// diff --git a/crates/sqlite-store/src/note/filters.rs b/crates/sqlite-store/src/note/filters.rs index cd5e193f0b..4a448af0bf 100644 --- a/crates/sqlite-store/src/note/filters.rs +++ b/crates/sqlite-store/src/note/filters.rs @@ -168,10 +168,11 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, }, NoteFilter::Consumed => { format!( - "(state_discriminant in ({}, {}, {}))", + "(state_discriminant in ({}, {}, {}, {}))", InputNoteState::STATE_CONSUMED_AUTHENTICATED_LOCAL, InputNoteState::STATE_CONSUMED_UNAUTHENTICATED_LOCAL, - InputNoteState::STATE_CONSUMED_EXTERNAL + InputNoteState::STATE_CONSUMED_EXTERNAL, + InputNoteState::STATE_CONSUMED_EXTERNAL_ERASED ) }, NoteFilter::Expected => { diff --git a/crates/sqlite-store/src/note/mod.rs b/crates/sqlite-store/src/note/mod.rs index cc2254ea32..0b1826732d 100644 --- a/crates/sqlite-store/src/note/mod.rs +++ b/crates/sqlite-store/src/note/mod.rs @@ -229,6 +229,7 @@ impl SqliteStore { Value::from(InputNoteState::STATE_CONSUMED_AUTHENTICATED_LOCAL.to_string()), Value::from(InputNoteState::STATE_CONSUMED_UNAUTHENTICATED_LOCAL.to_string()), Value::from(InputNoteState::STATE_CONSUMED_EXTERNAL.to_string()), + Value::from(InputNoteState::STATE_CONSUMED_EXTERNAL_ERASED.to_string()), ]); conn.prepare(QUERY) .into_store_error()? diff --git a/crates/sqlite-store/src/note/tests.rs b/crates/sqlite-store/src/note/tests.rs index 5f6b21ab8e..b691da2522 100644 --- a/crates/sqlite-store/src/note/tests.rs +++ b/crates/sqlite-store/src/note/tests.rs @@ -22,7 +22,7 @@ use miden_client::{Felt, ZERO}; use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; -use miden_protocol::note::NoteDetails; +use miden_protocol::note::{NoteDetails, NoteHeader}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE, @@ -55,6 +55,7 @@ fn create_consumed_external_input_note( nullifier_block_height: BlockNumber::from(block_height), consumer_account, consumed_tx_order: None, + metadata: None, }; InputNoteRecord::new(details, NoteAttachments::empty(), Some(0), state.into()) @@ -116,9 +117,98 @@ fn create_consumed_input_note_with_consumer( InputNoteRecord::new(details, NoteAttachments::empty(), Some(0), state.into()) } +/// Helper to create a header-only erased input note (state `ConsumedExternalErased`), i.e. a note +/// the client only knows via its [`NoteHeader`] because it was created and consumed in the same +/// batch. Built through [`InputNoteRecord::from_header`], so it carries no authoritative details. +fn create_erased_header_input_note( + consumer: AccountId, + index: u32, + block_height: u32, + consumed_tx_order: u32, +) -> InputNoteRecord { + let serial_number: Word = + [Felt::new_unchecked(u64::from(index) + 7000), ZERO, ZERO, ZERO].into(); + let assets = NoteAssets::new(vec![]).unwrap(); + let recipient = NoteRecipient::new( + serial_number, + StandardNote::SWAP.script(), + NoteStorage::new(vec![]).unwrap(), + ); + let details = NoteDetails::new(assets, recipient); + + let partial_metadata = + PartialNoteMetadata::new(consumer, NoteType::Public).with_tag(NoteTag::from(index)); + let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty()); + let header = NoteHeader::new(details.commitment(), metadata); + + let mut record = + InputNoteRecord::from_header(&header, BlockNumber::from(block_height), Some(consumer)); + record.set_consumed_tx_order(Some(consumed_tx_order)); + record +} + // INPUT NOTE READER TESTS // ================================================================================================ +/// The reader must return a single consumer's consumed notes in consumption order regardless of +/// whether each note carries full details (e.g. notes the client created or discovered) or is a +/// header-only erased note (state `ConsumedExternalErased`, no payload). This is the core proof +/// that an indexer can read an account's consumed notes, with erased and non-erased entries +/// intercalated in the right order. Note: this exercises the reader + the new state's storage, +/// serialization, filtering and ordering; how header-only records get created during sync is a +/// separate, node-dependent concern. +#[tokio::test] +async fn input_note_reader_interleaves_erased_and_full_notes() { + let store = create_test_store().await; + let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + + // The consumer consumes, across increasing blocks: full, erased, full, erased. + let full_b1 = create_consumed_input_note_with_consumer(consumer, 0, 1, 0); + let erased_b2 = create_erased_header_input_note(consumer, 1, 2, 0); + let full_b3 = create_consumed_input_note_with_consumer(consumer, 2, 3, 0); + let erased_b4 = create_erased_header_input_note(consumer, 3, 4, 0); + + let expected_ids = vec![full_b1.id(), erased_b2.id(), full_b3.id(), erased_b4.id()]; + + store + .upsert_input_notes(&[full_b1, erased_b2, full_b3, erased_b4]) + .await + .unwrap(); + + let store: Arc = Arc::new(store); + let mut reader = InputNoteReader::new(store, consumer); + + let mut collected = Vec::new(); + while let Some(note) = reader.next().await.unwrap() { + collected.push(note); + } + + // All four consumed notes are returned, interleaved, in consumption order. + assert_eq!(collected.len(), 4, "reader should return all four consumed notes"); + let collected_ids: Vec<_> = collected.iter().map(InputNoteRecord::id).collect(); + assert_eq!( + collected_ids, expected_ids, + "reader should return erased and full notes intercalated in consumption order", + ); + + // The erased entries are header-only; the full entries carry authoritative details. + assert!( + !collected[1].has_details(), + "block-2 note should be a header-only erased record" + ); + assert!( + !collected[3].has_details(), + "block-4 note should be a header-only erased record" + ); + assert!(collected[0].has_details(), "block-1 note should carry full details"); + assert!(collected[2].has_details(), "block-3 note should carry full details"); + + // Every entry is attributed to the consumer. + for note in &collected { + assert_eq!(note.consumer_account(), Some(consumer)); + } +} + #[tokio::test] async fn input_note_reader_returns_none_on_empty_store() { let store = create_test_store().await; diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 0297451331..7484cbe5cc 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -911,8 +911,8 @@ async fn import_note_validation() { .pop() .unwrap(); - // The consumed note is in `ConsumedExternal` state (no metadata), so it's retrieved by its - // details commitment rather than by `NoteId`. + // The consumed note is in `ConsumedExternal` state; it's retrieved by its details commitment, + // a stable identifier that does not depend on the note's metadata. let consumed_note = client .get_input_notes(NoteFilter::DetailsCommitments(vec![ consumed_note.note().unwrap().details_commitment(), @@ -1391,8 +1391,8 @@ async fn input_note_reader_finds_externally_consumed_notes() { // Sync: the client should discover the note was consumed externally by the tracked account. client.sync_state().await.unwrap(); - // The note is in ConsumedExternal state (no metadata), so it's retrieved by its details - // commitment rather than by `NoteId`. + // The note is in `ConsumedExternal` state; it's retrieved by its details commitment, a stable + // identifier that does not depend on the note's metadata. let input_note = client .get_input_notes(NoteFilter::DetailsCommitments(vec![p2id_details_commitment])) .await From 0a052ebfd975d60996bd9b9b55c1613cfe080b28 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Mon, 29 Jun 2026 18:09:02 -0300 Subject: [PATCH 2/8] docs(rust-client): avoid private intra-doc link in from_header The public `from_header` docs linked to the private `placeholder_details` function, which rustdoc rejects under `-D warnings` (the doc build). Reference it as inline code instead. --- .../rust-client/src/store/note_record/input_note_record/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rust-client/src/store/note_record/input_note_record/mod.rs b/crates/rust-client/src/store/note_record/input_note_record/mod.rs index e711f063d4..1c2799321c 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/mod.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/mod.rs @@ -101,7 +101,7 @@ impl InputNoteRecord { /// Creates a header-only record for an erased note (created and consumed in the same batch) /// that the client only knows via its [`NoteHeader`]. The record is placed in the /// [`InputNoteState::ConsumedExternalErased`] state, which carries the authoritative note id - /// and metadata; the `details` field is a placeholder (see [`placeholder_details`]) and + /// and metadata; the `details` field is a placeholder (see `placeholder_details`) and /// [`InputNoteRecord::has_details`] returns `false`. pub fn from_header( header: &NoteHeader, From 5cac6187d343ef87fe4198cddc9eb78fb9512e30 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Tue, 30 Jun 2026 15:20:20 -0300 Subject: [PATCH 3/8] refactor(rust-client): clarify erased-note docs and drop redundant serde guard Address review feedback on the erased-note recovery: - Drop the backward-compat guard in `ConsumedExternalNoteState::read_from` and read the metadata unconditionally, matching the sibling fields and the established pattern (the store has no per-version forward migration and no other state guards its serde this way). - Clarify comments and docs: use "erased notes" instead of "same-batch-erased", stop naming `InputNoteReader` in general sync paths, define the erased concept once in the `ConsumedExternalErased` state doc, and describe the state as holding any header-only unauthenticated input (typically an erased note) rather than only erased notes. - Display the state as "Consumed (header only, ...)" instead of "Consumed erased". --- bin/integration-tests/src/tests/onchain.rs | 11 +++++----- .../src/note/note_update_tracker.rs | 21 ++++++++----------- .../rust-client/src/rpc/domain/transaction.rs | 6 +++--- .../note_record/input_note_record/mod.rs | 5 +++-- .../states/consumed_external.rs | 8 +------ .../states/consumed_external_erased.rs | 10 ++++----- .../input_note_record/states/mod.rs | 8 +++---- crates/rust-client/src/sync/state_sync.rs | 15 ++++++------- crates/sqlite-store/src/note/tests.rs | 4 ++-- .../testing/miden-client-tests/src/tests.rs | 6 ++---- 10 files changed, 43 insertions(+), 51 deletions(-) diff --git a/bin/integration-tests/src/tests/onchain.rs b/bin/integration-tests/src/tests/onchain.rs index 634102b321..bee9e31f93 100644 --- a/bin/integration-tests/src/tests/onchain.rs +++ b/bin/integration-tests/src/tests/onchain.rs @@ -721,12 +721,13 @@ pub async fn test_consumed_note_ordering(client_config: ClientConfig) -> Result< /// notes the account consumed, intercalated in consumption order. /// /// Client A owns the faucet and the consumer and drives all activity; client B only -/// `import_account_by_id`s the consumer. Across increasing blocks the consumer consumes: an erased +/// `import_account_by_id`s the consumer. Across increasing blocks the consumer consumes an erased /// note (minted and consumed in the same batch, so it never lands in a block), then a committed -/// note, then another erased note. B syncs and its `InputNoteReader` returns all three in order: -/// the erased ones surface as header-only records (their headers ride the consumer's transaction as -/// unauthenticated input commitments), the committed one in full. This exercises the consumer-side -/// header path end to end, and confirms the node populates the header for unauthenticated inputs. +/// note, then another erased note. B syncs and its `InputNoteReader` returns all three in order, +/// with the erased ones surfacing as header-only records (their headers ride the consumer's +/// transaction as unauthenticated input commitments) and the committed one in full. This exercises +/// the consumer-side header path end to end, and confirms the node populates the header for +/// unauthenticated inputs. pub async fn test_importer_note_reader_finds_erased_and_committed_interleaved( client_config: ClientConfig, ) -> Result<()> { diff --git a/crates/rust-client/src/note/note_update_tracker.rs b/crates/rust-client/src/note/note_update_tracker.rs index ddfabd1a92..22041ccb40 100644 --- a/crates/rust-client/src/note/note_update_tracker.rs +++ b/crates/rust-client/src/note/note_update_tracker.rs @@ -322,7 +322,7 @@ impl NoteUpdateTracker { /// Returns the ids of updated input notes that are now consumed, by tracking key. The key (the /// id assigned when the record was inserted) is used rather than `InputNoteRecord::id`, which /// can be `None` for a consumed record whose state carries no metadata (e.g. an - /// externally-consumed note imported from bare `NoteFile::NoteDetails`). + /// externally-consumed note imported from `NoteFile::NoteDetails`). pub fn consumed_input_note_ids(&self) -> impl Iterator + '_ { self.input_notes .iter() @@ -514,16 +514,13 @@ impl NoteUpdateTracker { Ok(()) } - /// Records a note consumed by a tracked account that the client only learns about from the - /// consuming transaction's input commitment. + /// Inserts a header-only consumed input note for a tracked account's transaction. /// - /// Unauthenticated input notes (which includes same-batch-erased notes) carry their full - /// [`NoteHeader`] in the transaction. When the client holds neither an input nor an output - /// record for such a note (i.e. it is following the consuming account rather than having - /// created the note), this builds a header-only record attributed to the `consumer` so the - /// consumption surfaces through [`crate::note::InputNoteReader`]. No-op when the commitment - /// carries no header (authenticated notes, discoverable via the note tag instead) or the note - /// is already tracked (the full-detail paths own that case). + /// Header-bearing input commitments are unauthenticated inputs (typically erased notes). When + /// the client tracks neither an input nor an + /// output record for the note, this stores its header under the consuming account so the + /// consumed-note queries can return it. No-op for authenticated commitments (which carry no + /// header) and for notes already tracked. pub(crate) fn insert_consumed_unauthenticated_note( &mut self, commitment: &InputNoteCommitment, @@ -538,8 +535,8 @@ impl NoteUpdateTracker { return; } - // Preserve the note's position within the block when known; fall back to 0 so the record - // still satisfies the reader's "has a consumption order" requirement. + // Fall back to 0 when the block position is unknown, since `get_input_note_by_offset` + // excludes notes without a consumption order. let order = self.get_nullifier_order(commitment.nullifier()).or(Some(0)); let mut record = InputNoteRecord::from_header(header, block_num, Some(consumer)); record.set_consumed_tx_order(order); diff --git a/crates/rust-client/src/rpc/domain/transaction.rs b/crates/rust-client/src/rpc/domain/transaction.rs index b82134e877..b6c47d0c6a 100644 --- a/crates/rust-client/src/rpc/domain/transaction.rs +++ b/crates/rust-client/src/rpc/domain/transaction.rs @@ -135,9 +135,9 @@ fn convert_transaction_header( .ok_or(RpcError::ExpectedDataMissing("nullifier".into()))? .try_into() .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?; - // Unauthenticated input notes (which includes same-batch-erased notes) carry their - // full header here; preserve it so the consuming account's sync can surface the note - // even when the client never held its details. + // Unauthenticated input notes (typically erased notes) carry their full header here; + // preserve it so the consuming account's sync can record the note even when the client + // never held its details. let header = d .header .map(NoteHeader::try_from) diff --git a/crates/rust-client/src/store/note_record/input_note_record/mod.rs b/crates/rust-client/src/store/note_record/input_note_record/mod.rs index 1c2799321c..d05d42fdb9 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/mod.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/mod.rs @@ -98,8 +98,9 @@ impl InputNoteRecord { InputNoteRecord { details, attachments, created_at, state } } - /// Creates a header-only record for an erased note (created and consumed in the same batch) - /// that the client only knows via its [`NoteHeader`]. The record is placed in the + /// Creates a header-only record for a note consumed as an unauthenticated input (typically an + /// erased note) that the client only knows via its + /// [`NoteHeader`]. The record is placed in the /// [`InputNoteState::ConsumedExternalErased`] state, which carries the authoritative note id /// and metadata; the `details` field is a placeholder (see `placeholder_details`) and /// [`InputNoteRecord::has_details`] returns `false`. diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external.rs b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external.rs index 213709bfc8..0e91999e5c 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external.rs @@ -101,13 +101,7 @@ impl miden_tx::utils::serde::Deserializable for ConsumedExternalNoteState { let nullifier_block_height = BlockNumber::read_from(source)?; let consumer_account = Option::::read_from(source)?; let consumed_tx_order = Option::::read_from(source)?; - // Older persisted records have no metadata tail; when no bytes remain, decode it as `None`. - // The state owns its store column, so any remaining bytes belong to this field. - let metadata = if source.has_more_bytes() { - Option::::read_from(source)? - } else { - None - }; + let metadata = Option::::read_from(source)?; Ok(ConsumedExternalNoteState { nullifier_block_height, consumer_account, diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs index 1ae1f6971c..d4fbd78c02 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs @@ -10,11 +10,11 @@ use crate::store::NoteRecordError; /// Information related to notes in the [`InputNoteState::ConsumedExternalErased`] state. /// -/// A record enters this state when the client learns about an erased note (created and consumed in -/// the same batch) whose full details it never held, only the note header carried in the consuming -/// transaction. With no authoritative [`miden_protocol::note::NoteDetails`], the note id cannot be -/// derived the usual way (from the details commitment and metadata), so it is carried in the state -/// directly and surfaced through [`crate::note::InputNoteReader`]. +/// A record enters this state when a tracked account consumes a note as an unauthenticated input +/// (typically an erased note, created and consumed in the same batch) whose full details the client +/// never held, only the note header carried in the consuming transaction. With no authoritative +/// [`miden_protocol::note::NoteDetails`], the note id cannot be derived the usual way (from the +/// details commitment and metadata), so it is stored in the state directly. #[derive(Clone, Debug, PartialEq)] pub struct ConsumedExternalErasedNoteState { /// The note id, stored directly. Unlike full records, it cannot be derived from the record's diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/mod.rs b/crates/rust-client/src/store/note_record/input_note_record/states/mod.rs index d2f4c8432a..a7a06ce977 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/mod.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/mod.rs @@ -60,8 +60,8 @@ pub enum InputNoteState { ConsumedUnauthenticatedLocal(ConsumedUnauthenticatedLocalNoteState), /// Note consumed by a transaction not submitted by this client and confirmed by the network. ConsumedExternal(ConsumedExternalNoteState), - /// Note discovered as erased (created and consumed within the same batch) for which the - /// client never held the full details, only the header from the consuming transaction. + /// Note consumed by a transaction not submitted by this client, known only by the header + /// carried on the consuming transaction (typically an erased note). ConsumedExternalErased(ConsumedExternalErasedNoteState), } @@ -351,11 +351,11 @@ impl Display for InputNoteState { if let Some(account) = state.consumer_account { write!( f, - "Consumed erased (at block {} by tracked account {})", + "Consumed (header only, at block {} by tracked account {})", state.nullifier_block_height, account ) } else { - write!(f, "Consumed erased (at block {})", state.nullifier_block_height) + write!(f, "Consumed (header only, at block {})", state.nullifier_block_height) } }, } diff --git a/crates/rust-client/src/sync/state_sync.rs b/crates/rust-client/src/sync/state_sync.rs index d5a91c9862..f5ec0ed88e 100644 --- a/crates/rust-client/src/sync/state_sync.rs +++ b/crates/rust-client/src/sync/state_sync.rs @@ -647,9 +647,9 @@ impl StateSync { // Detect output notes erased by same-batch note erasure. Self::mark_erased_notes_as_consumed(state_sync_update, transaction); - // Surface notes consumed by this tracked account that the client only learns about from - // the consuming transaction's unauthenticated input commitments (e.g. same-batch-erased - // notes it never created or discovered). + // Record notes consumed by this tracked account that the client only learns about from + // the consuming transaction's unauthenticated input commitments (typically erased notes + // it never created or discovered). Self::record_consumed_unauthenticated_notes(state_sync_update, transaction); } @@ -673,10 +673,11 @@ impl StateSync { } } - /// Records notes consumed by a tracked account that arrive only as unauthenticated input - /// commitments on the consuming transaction (which includes same-batch-erased notes). The - /// consumer is the account that executed the transaction; the note's header travels with the - /// commitment, so a follower can surface the consumption even without ever holding the note. + /// Records header-bearing input commitments from a tracked account's transaction. + /// + /// Header-bearing commitments are unauthenticated inputs; the transaction's account is their + /// consumer. The note's header travels with the commitment, so the consumption can be recorded + /// even when the client never held the note's details. fn record_consumed_unauthenticated_notes( state_sync_update: &mut StateSyncUpdate, transaction: &RpcTransactionRecord, diff --git a/crates/sqlite-store/src/note/tests.rs b/crates/sqlite-store/src/note/tests.rs index b691da2522..c590990f36 100644 --- a/crates/sqlite-store/src/note/tests.rs +++ b/crates/sqlite-store/src/note/tests.rs @@ -154,7 +154,7 @@ fn create_erased_header_input_note( /// whether each note carries full details (e.g. notes the client created or discovered) or is a /// header-only erased note (state `ConsumedExternalErased`, no payload). This is the core proof /// that an indexer can read an account's consumed notes, with erased and non-erased entries -/// intercalated in the right order. Note: this exercises the reader + the new state's storage, +/// intercalated in the right order. It exercises the reader + the new state's storage, /// serialization, filtering and ordering; how header-only records get created during sync is a /// separate, node-dependent concern. #[tokio::test] @@ -162,7 +162,7 @@ async fn input_note_reader_interleaves_erased_and_full_notes() { let store = create_test_store().await; let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); - // The consumer consumes, across increasing blocks: full, erased, full, erased. + // The consumer consumes full, erased, full, erased across increasing blocks. let full_b1 = create_consumed_input_note_with_consumer(consumer, 0, 1, 0); let erased_b2 = create_erased_header_input_note(consumer, 1, 2, 0); let full_b3 = create_consumed_input_note_with_consumer(consumer, 2, 3, 0); diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 7484cbe5cc..087e1c139f 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -911,8 +911,7 @@ async fn import_note_validation() { .pop() .unwrap(); - // The consumed note is in `ConsumedExternal` state; it's retrieved by its details commitment, - // a stable identifier that does not depend on the note's metadata. + // The consumed note is in `ConsumedExternal` state; it's retrieved by its details commitment. let consumed_note = client .get_input_notes(NoteFilter::DetailsCommitments(vec![ consumed_note.note().unwrap().details_commitment(), @@ -1391,8 +1390,7 @@ async fn input_note_reader_finds_externally_consumed_notes() { // Sync: the client should discover the note was consumed externally by the tracked account. client.sync_state().await.unwrap(); - // The note is in `ConsumedExternal` state; it's retrieved by its details commitment, a stable - // identifier that does not depend on the note's metadata. + // The note is in `ConsumedExternal` state; it's retrieved by its details commitment. let input_note = client .get_input_notes(NoteFilter::DetailsCommitments(vec![p2id_details_commitment])) .await From fc00ff3f2b12bdbd4b50ff084e08329902867840 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Tue, 30 Jun 2026 15:27:00 -0300 Subject: [PATCH 4/8] docs(rust-client): add changelog entry for erased-note recovery --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c10ba34f9..80834287d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +* [FEATURE][rust] `InputNoteReader` now returns the erased notes a followed account consumed, surfaced as header-only records in the new `InputNoteState::ConsumedExternalErased` state ([#2293](https://github.com/0xMiden/miden-client/pull/2293)). + ### Fixes * [FIX][rust] RPC endpoint parsing now rejects endpoint strings that omit either the protocol or host. ([#2266](https://github.com/0xMiden/miden-client/pull/2266)) From 895a13b2c1a38e392fc5c30a943dbbbe44287211 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Tue, 14 Jul 2026 18:22:18 -0300 Subject: [PATCH 5/8] fix(rust-client): store real nullifier for erased consumed notes Header-only ConsumedExternalErased records have placeholder details, so their nullifier cannot be recomputed. Store the nullifier carried by the consuming transaction's InputNoteCommitment instead. --- .../src/note/note_update_tracker.rs | 30 ++++++++++++++++++- .../note_record/input_note_record/mod.rs | 17 ++++++++--- .../states/consumed_external_erased.rs | 9 +++++- crates/sqlite-store/src/note/mod.rs | 10 +++---- crates/sqlite-store/src/note/tests.rs | 10 +++++-- 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/rust-client/src/note/note_update_tracker.rs b/crates/rust-client/src/note/note_update_tracker.rs index 777382388c..28e0b0e844 100644 --- a/crates/rust-client/src/note/note_update_tracker.rs +++ b/crates/rust-client/src/note/note_update_tracker.rs @@ -539,7 +539,8 @@ impl NoteUpdateTracker { // Fall back to 0 when the block position is unknown, since `get_input_note_by_offset` // excludes notes without a consumption order. let order = self.get_nullifier_order(commitment.nullifier()).or(Some(0)); - let mut record = InputNoteRecord::from_header(header, block_num, Some(consumer)); + let mut record = + InputNoteRecord::from_header(header, commitment.nullifier(), block_num, Some(consumer)); record.set_consumed_tx_order(order); self.insert_input_note(record, NoteUpdateType::Insert); } @@ -848,11 +849,13 @@ mod tests { NoteAssets, NoteAttachments, NoteDetails, + NoteHeader, NoteId, NoteMetadata, NoteRecipient, NoteStorage, NoteType, + Nullifier, PartialNoteMetadata, }; use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; @@ -970,6 +973,31 @@ mod tests { assert_eq!(tracker.updated_input_notes().count(), 1); } + #[test] + fn header_only_record_carries_the_transaction_nullifier() { + // A header-only erased record stores placeholder details, so its nullifier cannot be + // recomputed from them. It must instead carry the nullifier from the consuming + // transaction's input commitment, unchanged and surviving a serialization round-trip. + let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap(); + let details = note_details(20); + let metadata = note_metadata(sender); + let header = NoteHeader::new(details.commitment(), metadata); + let nullifier = Nullifier::from_details_and_metadata(&details, &metadata); + + let record = + InputNoteRecord::from_header(&header, nullifier, BlockNumber::from(7u32), Some(sender)); + + assert_eq!(record.nullifier(), Some(nullifier)); + assert_ne!( + record.nullifier(), + Some(Nullifier::from_details_and_metadata(record.details(), &metadata)), + "the nullifier must be the stored one, not one derived from the placeholder details" + ); + + let restored = InputNoteRecord::read_from_bytes(&record.to_bytes()).unwrap(); + assert_eq!(restored.nullifier(), Some(nullifier)); + } + #[test] fn external_consumption_retains_note_id() { let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap(); diff --git a/crates/rust-client/src/store/note_record/input_note_record/mod.rs b/crates/rust-client/src/store/note_record/input_note_record/mod.rs index d05d42fdb9..60ef279afe 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/mod.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/mod.rs @@ -101,16 +101,20 @@ impl InputNoteRecord { /// Creates a header-only record for a note consumed as an unauthenticated input (typically an /// erased note) that the client only knows via its /// [`NoteHeader`]. The record is placed in the - /// [`InputNoteState::ConsumedExternalErased`] state, which carries the authoritative note id - /// and metadata; the `details` field is a placeholder (see `placeholder_details`) and - /// [`InputNoteRecord::has_details`] returns `false`. + /// [`InputNoteState::ConsumedExternalErased`] state, which carries the authoritative note id, + /// nullifier and metadata; the `details` field is a placeholder (see `placeholder_details`) and + /// [`InputNoteRecord::has_details`] returns `false`. The `nullifier` is the one carried by the + /// consuming transaction's input commitment, since it cannot be recomputed from the placeholder + /// details. pub fn from_header( header: &NoteHeader, + nullifier: Nullifier, nullifier_block_height: BlockNumber, consumer_account: Option, ) -> InputNoteRecord { let state = ConsumedExternalErasedNoteState { note_id: header.id(), + nullifier, metadata: *header.metadata(), nullifier_block_height, consumer_account, @@ -200,8 +204,13 @@ impl InputNoteRecord { self.state.metadata() } - /// Returns the note nullifier, if the record contains the [`NoteMetadata`]. + /// Returns the note nullifier, if the record contains the [`NoteMetadata`]. For header-only + /// records (state [`InputNoteState::ConsumedExternalErased`]) this is read from the state, + /// since it cannot be recomputed from the placeholder details. pub fn nullifier(&self) -> Option { + if let InputNoteState::ConsumedExternalErased(s) = &self.state { + return Some(s.nullifier); + } let metadata = self.metadata()?; Some(Nullifier::from_details_and_metadata(&self.details, metadata)) } diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs index d4fbd78c02..e125499538 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs @@ -2,7 +2,7 @@ use alloc::string::ToString; use miden_protocol::account::AccountId; use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::note::{NoteId, NoteInclusionProof, NoteMetadata}; +use miden_protocol::note::{NoteId, NoteInclusionProof, NoteMetadata, Nullifier}; use miden_protocol::transaction::TransactionId; use super::{InputNoteState, NoteStateHandler}; @@ -20,6 +20,10 @@ pub struct ConsumedExternalErasedNoteState { /// The note id, stored directly. Unlike full records, it cannot be derived from the record's /// details, which are a placeholder for this state. pub note_id: NoteId, + /// The note nullifier, stored directly for the same reason as `note_id`. It cannot be + /// recomputed from the placeholder details, so it is taken from the consuming + /// transaction's input commitment. + pub nullifier: Nullifier, /// Metadata associated with the note, including sender, note type, tag and other additional /// information. pub metadata: NoteMetadata, @@ -92,6 +96,7 @@ impl NoteStateHandler for ConsumedExternalErasedNoteState { impl miden_tx::utils::serde::Serializable for ConsumedExternalErasedNoteState { fn write_into(&self, target: &mut W) { self.note_id.write_into(target); + self.nullifier.write_into(target); self.metadata.write_into(target); self.nullifier_block_height.write_into(target); self.consumer_account.write_into(target); @@ -104,12 +109,14 @@ impl miden_tx::utils::serde::Deserializable for ConsumedExternalErasedNoteState source: &mut R, ) -> Result { let note_id = NoteId::read_from(source)?; + let nullifier = Nullifier::read_from(source)?; let metadata = NoteMetadata::read_from(source)?; let nullifier_block_height = BlockNumber::read_from(source)?; let consumer_account = Option::::read_from(source)?; let consumed_tx_order = Option::::read_from(source)?; Ok(ConsumedExternalErasedNoteState { note_id, + nullifier, metadata, nullifier_block_height, consumer_account, diff --git a/crates/sqlite-store/src/note/mod.rs b/crates/sqlite-store/src/note/mod.rs index d36212e4dd..c9145f1b75 100644 --- a/crates/sqlite-store/src/note/mod.rs +++ b/crates/sqlite-store/src/note/mod.rs @@ -408,13 +408,11 @@ fn parse_input_note( /// Serialize the provided input note into database compatible types. fn serialize_input_note(note: &InputNoteRecord) -> SerializedInputNoteData { let details_commitment = note.details_commitment().to_hex(); - // `note_id` and `nullifier` require metadata, so they're only available when the record - // carries it. The columns are NULL-able and get populated once metadata arrives (via - // sync / inclusion proof). + // `note_id` and `nullifier` are only available when the record carries metadata (header-only + // records supply both directly from their state). The columns are NULL-able and get populated + // once metadata arrives (via sync / inclusion proof). let id = note.id().map(|id| id.as_word().to_string()); - let nullifier = note.metadata().map(|metadata| { - miden_client::note::Nullifier::from_details_and_metadata(note.details(), metadata).to_hex() - }); + let nullifier = note.nullifier().map(|nullifier| nullifier.to_hex()); let created_at = note.created_at().unwrap_or(0); let details = note.details(); diff --git a/crates/sqlite-store/src/note/tests.rs b/crates/sqlite-store/src/note/tests.rs index c590990f36..63174e65ba 100644 --- a/crates/sqlite-store/src/note/tests.rs +++ b/crates/sqlite-store/src/note/tests.rs @@ -9,6 +9,7 @@ use miden_client::note::{ NoteStorage, NoteTag, NoteType, + Nullifier, PartialNoteMetadata, }; use miden_client::store::input_note_states::{ @@ -140,9 +141,14 @@ fn create_erased_header_input_note( PartialNoteMetadata::new(consumer, NoteType::Public).with_tag(NoteTag::from(index)); let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty()); let header = NoteHeader::new(details.commitment(), metadata); + let nullifier = Nullifier::from_details_and_metadata(&details, &metadata); - let mut record = - InputNoteRecord::from_header(&header, BlockNumber::from(block_height), Some(consumer)); + let mut record = InputNoteRecord::from_header( + &header, + nullifier, + BlockNumber::from(block_height), + Some(consumer), + ); record.set_consumed_tx_order(Some(consumed_tx_order)); record } From 41c700257a92a3c40c57715319074846c956252b Mon Sep 17 00:00:00 2001 From: JereSalo Date: Tue, 14 Jul 2026 19:06:39 -0300 Subject: [PATCH 6/8] fix(rust-client): avoid duplicate record for externally-consumed bare-details note When a note imported from bare details (metadata-less, tracked only by its details commitment) is later recorded as a consumed unauthenticated input, the guard did not recognize it and inserted a second, header-only placeholder record under a different (placeholder) details commitment. That left a duplicate row alongside the stale expected one. Extend the guard to also skip when a tracked metadata-less expected note matches the committed note. This prevents the duplicate and matches the behavior of a client without erased-note recovery. Evolving the expected record straight to a consumed state is left as a follow-up. --- .../src/note/note_update_tracker.rs | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/rust-client/src/note/note_update_tracker.rs b/crates/rust-client/src/note/note_update_tracker.rs index 28e0b0e844..a2b42c2f4f 100644 --- a/crates/rust-client/src/note/note_update_tracker.rs +++ b/crates/rust-client/src/note/note_update_tracker.rs @@ -531,7 +531,20 @@ impl NoteUpdateTracker { return; }; let note_id = header.id(); - if self.input_notes_by_id.contains_key(¬e_id) || self.output_notes.contains_key(¬e_id) + // Skip when the note is already tracked in any form, whether as a metadata-bearing input + // (`input_notes_by_id`), an output note, or a metadata-less expected note imported from + // bare details (`expected_note_matching`, which `input_notes_by_id` does not + // cover). In that last case a header-only placeholder would land under a different + // (placeholder) details commitment, leaving a duplicate row alongside the stale + // expected one. + // + // For that metadata-less case this only prevents the duplicate; the existing expected + // record is left as-is and keeps showing as unspent. Evolving it straight to a consumed + // state from this consumption event is a deferred follow-up, and skipping matches a + // client without erased-note recovery, which never recovers this note either. + if self.input_notes_by_id.contains_key(¬e_id) + || self.output_notes.contains_key(¬e_id) + || self.expected_note_matching(note_id, header.metadata()).is_some() { return; } @@ -859,7 +872,7 @@ mod tests { PartialNoteMetadata, }; use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; - use miden_protocol::transaction::TransactionId; + use miden_protocol::transaction::{InputNoteCommitment, TransactionId}; use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_protocol::{Felt, Word, ZERO}; use miden_standards::note::StandardNote; @@ -998,6 +1011,26 @@ mod tests { assert_eq!(restored.nullifier(), Some(nullifier)); } + #[test] + fn consumed_unauthenticated_note_skips_tracked_metadata_less_note() { + // A metadata-less expected note (imported from bare details) is tracked by its details + // commitment and absent from `input_notes_by_id`. Recording the same note as a consumed + // unauthenticated input must recognize it and not insert a second, header-only placeholder. + let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap(); + let expected = expected_note(30); + let details_commitment = expected.details_commitment(); + let metadata = note_metadata(sender); + let header = NoteHeader::new(details_commitment, metadata); + let nullifier = Nullifier::from_details_and_metadata(expected.details(), &metadata); + let commitment = InputNoteCommitment::from_parts_unchecked(nullifier, Some(header)); + + let mut tracker = NoteUpdateTracker::new(vec![expected], vec![]); + tracker.insert_consumed_unauthenticated_note(&commitment, sender, BlockNumber::from(5u32)); + + // The existing expected note is recognized, so no new (placeholder) row is inserted. + assert_eq!(tracker.updated_input_notes().count(), 0); + } + #[test] fn external_consumption_retains_note_id() { let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap(); From c4b9ec73895f763b9ab0e62a25d73fa42ccc11ae Mon Sep 17 00:00:00 2001 From: JereSalo Date: Fri, 17 Jul 2026 17:00:59 -0300 Subject: [PATCH 7/8] fix(rust-client): store real details commitment for erased notes and stop exposing placeholder data Header-only records (state ConsumedExternalErased) previously derived their identity from placeholder details and let those placeholder values escape through conversions and the CLI. The state now stores the details commitment taken from the note header, so details_commitment(), id() and commitment() report authoritative values and the store row is keyed by the real commitment. Conversions to Note and InputNote fail for header-only records instead of fabricating a note, and From for NoteDetails became TryFrom. The CLI gates details-derived fields behind has_details(), falling back to a matching output record when present and showing them as unavailable otherwise. Replacing the placeholder entirely with an explicit payload model (optional details and nullable store columns) is left for a follow-up. --- CHANGELOG.md | 1 + bin/integration-tests/src/tests/client.rs | 8 +- bin/miden-cli/src/commands/notes.rs | 84 +++++++++--------- .../src/note/note_update_tracker.rs | 54 ++++++++---- .../note_record/input_note_record/mod.rs | 85 ++++++++++--------- .../states/consumed_external_erased.rs | 29 ++++--- .../testing/miden-client-tests/src/tests.rs | 2 +- 7 files changed, 150 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ce3bd6c8..dea61ce5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * [BREAKING][store] The SQLite store now stores account IDs as serialized `BLOB` columns instead of hex `TEXT` ([#2309](https://github.com/0xMiden/rust-sdk/pull/2309)). * [BREAKING][param][store] `Store::insert_block_header` now takes a `nodes` argument and persists the header with its MMR authentication nodes in a single transaction; the standalone `Store::insert_partial_blockchain_nodes` is removed. Header-only inserts (e.g. genesis) pass an empty slice ([#2294](https://github.com/0xMiden/rust-sdk/pull/2294)). * [BREAKING][behavior][store] The `ConsumedExternal` note-metadata layout added in [#2308](https://github.com/0xMiden/rust-sdk/pull/2308) is now the only supported serialized format. The backward-compatible decoding of the older metadata-less layout is removed, so existing stores are not compatible and must be recreated ([#2313](https://github.com/0xMiden/rust-sdk/pull/2313)). +* [BREAKING][rust] `From for NoteDetails` is now `TryFrom`, since the header-only records introduced by the new `ConsumedExternalErased` state carry no note details ([#2293](https://github.com/0xMiden/rust-sdk/pull/2293)). ### Features diff --git a/bin/integration-tests/src/tests/client.rs b/bin/integration-tests/src/tests/client.rs index 82fa500721..08ad2b5c7f 100644 --- a/bin/integration-tests/src/tests/client.rs +++ b/bin/integration-tests/src/tests/client.rs @@ -285,7 +285,7 @@ pub async fn test_import_expected_notes(client_config: ClientConfig) -> Result<( client_2.add_note_tag(note.metadata().unwrap().tag()).await.unwrap(); client_2 .import_notes(&[NoteFile::NoteDetails { - details: note.clone().into(), + details: note.clone().try_into().expect("note record carries full details"), after_block_num: client_1.get_sync_height().await.unwrap(), tag: Some(note.metadata().unwrap().tag()), }]) @@ -362,7 +362,7 @@ pub async fn test_import_expected_note_uncommitted(client_config: ClientConfig) // If the verification is requested before execution then the import should fail let imported_commitment = client_2 .import_notes(&[NoteFile::NoteDetails { - details: note.into(), + details: note.try_into().expect("note record carries full details"), after_block_num: 0.into(), tag: None, }]) @@ -413,7 +413,7 @@ pub async fn test_import_expected_notes_from_the_past_as_committed( // importing the note before client_2 is synced will result in a note with `Expected` state let commitment = client_2 .import_notes(&[NoteFile::NoteDetails { - details: note.clone().into(), + details: note.clone().try_into().expect("note record carries full details"), after_block_num: block_height_before, tag: Some(note.metadata().unwrap().tag()), }]) @@ -433,7 +433,7 @@ pub async fn test_import_expected_notes_from_the_past_as_committed( assert!( client_2 .import_notes(&[NoteFile::NoteDetails { - details: note.clone().into(), + details: note.clone().try_into().expect("note record carries full details"), after_block_num: block_height_before, tag: Some(note.metadata().unwrap().tag()), }]) diff --git a/bin/miden-cli/src/commands/notes.rs b/bin/miden-cli/src/commands/notes.rs index 50a31e5862..8311f9ab4b 100644 --- a/bin/miden-cli/src/commands/notes.rs +++ b/bin/miden-cli/src/commands/notes.rs @@ -209,7 +209,7 @@ async fn show_note( // Identify if this is a standard note type by script root let script_root_word = match (&input_note_record, &output_note_record) { - (Some(record), _) => Some(record.details().script().root()), + (Some(record), _) if record.has_details() => Some(record.details().script().root()), (_, Some(record)) => record.recipient().map(|r| r.script().root()), _ => None, }; @@ -231,13 +231,13 @@ async fn show_note( println!("{table}"); let inputs = match (&input_note_record, &output_note_record) { - (Some(record), _) => { - let details = record.details(); - Some(details.storage().items().to_vec()) + (Some(record), _) if record.has_details() => { + Some(record.details().storage().items().to_vec()) }, (_, Some(record)) => { record.recipient().map(|recipient| recipient.storage().items().to_vec()) }, + (Some(_), None) => None, (None, None) => { panic!("One of the two records should be Some") }, @@ -245,40 +245,42 @@ async fn show_note( let assets = input_note_record .clone() + .filter(InputNoteRecord::has_details) .map(|record| record.assets().clone()) - .or(output_note_record.clone().map(|record| record.assets().clone())) - .expect("One of the two records should be Some"); + .or(output_note_record.clone().map(|record| record.assets().clone())); // print note vault - let mut table = create_dynamic_table(&["Note Assets"]); - table - .load_preset(presets::UTF8_HORIZONTAL_ONLY) - .set_content_arrangement(ContentArrangement::DynamicFullWidth); + if let Some(assets) = assets { + let mut table = create_dynamic_table(&["Note Assets"]); + table + .load_preset(presets::UTF8_HORIZONTAL_ONLY) + .set_content_arrangement(ContentArrangement::DynamicFullWidth); - table.add_row(vec![ - Cell::new("Type").add_attribute(Attribute::Bold), - Cell::new("Faucet ID").add_attribute(Attribute::Bold), - Cell::new("Amount").add_attribute(Attribute::Bold), - ]); - let resolver = load_faucet_metadata_resolver()?; - let assets = assets.iter(); - - for asset in assets { - let (asset_type, faucet, amount) = match asset { - Asset::Fungible(fungible_asset) => { - let (faucet, amount) = - resolver.format_fungible_asset(client, fungible_asset).await?; - ("Fungible Asset", faucet, amount) - }, - Asset::NonFungible(non_fungible_asset) => ( - "Non Fungible Asset", - non_fungible_asset.faucet_id().prefix().to_hex(), - 1.0.to_string(), - ), - }; - table.add_row(vec![asset_type, &faucet, &amount.clone()]); + table.add_row(vec![ + Cell::new("Type").add_attribute(Attribute::Bold), + Cell::new("Faucet ID").add_attribute(Attribute::Bold), + Cell::new("Amount").add_attribute(Attribute::Bold), + ]); + let resolver = load_faucet_metadata_resolver()?; + let assets = assets.iter(); + + for asset in assets { + let (asset_type, faucet, amount) = match asset { + Asset::Fungible(fungible_asset) => { + let (faucet, amount) = + resolver.format_fungible_asset(client, fungible_asset).await?; + ("Fungible Asset", faucet, amount) + }, + Asset::NonFungible(non_fungible_asset) => ( + "Non Fungible Asset", + non_fungible_asset.faucet_id().prefix().to_hex(), + 1.0.to_string(), + ), + }; + table.add_row(vec![asset_type, &faucet, &amount.clone()]); + } + println!("{table}"); } - println!("{table}"); if let Some(inputs) = inputs { let inputs = NoteStorage::new(inputs.clone()).map_err(ClientError::NoteError)?; @@ -300,12 +302,15 @@ async fn show_note( if with_code { let mut table = create_dynamic_table(&["Note Code"]); let code = match (&input_note_record, &output_note_record) { - (Some(record), _) => record.details().script().to_pretty_string(), + (Some(record), _) if record.has_details() => { + record.details().script().to_pretty_string() + }, (_, Some(record)) => { record.state().recipient().map_or("Code unavailable".to_string(), |recipient| { recipient.script().to_pretty_string() }) }, + (Some(_), None) => "Code unavailable".to_string(), (None, None) => { panic!("One of the two records should be Some") }, @@ -460,13 +465,14 @@ fn note_summary( .expect("One of the two records should be Some"); let assets_commitment_str = input_note_record + .filter(|record| record.has_details()) .map(|record| record.assets().commitment().to_string()) - .or(output_note_record.map(|record| record.assets().commitment().to_string())) - .expect("One of the two records should be Some"); + .or_else(|| output_note_record.map(|record| record.assets().commitment().to_string())) + .unwrap_or_else(|| "-".to_string()); let (inputs_commitment_str, serial_num, script_root_str) = match (input_note_record, output_note_record) { - (Some(record), _) => { + (Some(record), _) if record.has_details() => { let details = record.details(); ( details.storage().commitment().to_string(), @@ -474,7 +480,7 @@ fn note_summary( details.script().root().to_string(), ) }, - (None, Some(record)) if record.recipient().is_some() => { + (_, Some(record)) if record.recipient().is_some() => { let recipient = record.recipient().expect("output record should have recipient"); ( recipient.storage().commitment().to_string(), @@ -482,7 +488,7 @@ fn note_summary( recipient.script().root().to_string(), ) }, - (None, Some(_record)) => ("-".to_string(), "-".to_string(), "-".to_string()), + (Some(_), _) | (_, Some(_)) => ("-".to_string(), "-".to_string(), "-".to_string()), (None, None) => panic!("One of the two records should be Some"), }; diff --git a/crates/rust-client/src/note/note_update_tracker.rs b/crates/rust-client/src/note/note_update_tracker.rs index a2b42c2f4f..b2e90de5fd 100644 --- a/crates/rust-client/src/note/note_update_tracker.rs +++ b/crates/rust-client/src/note/note_update_tracker.rs @@ -531,20 +531,16 @@ impl NoteUpdateTracker { return; }; let note_id = header.id(); - // Skip when the note is already tracked in any form, whether as a metadata-bearing input - // (`input_notes_by_id`), an output note, or a metadata-less expected note imported from - // bare details (`expected_note_matching`, which `input_notes_by_id` does not - // cover). In that last case a header-only placeholder would land under a different - // (placeholder) details commitment, leaving a duplicate row alongside the stale - // expected one. + // Skip when the note is already tracked as an input note under the same details + // commitment or as an output note; inserting a header-only record here would collide + // with the existing record. // - // For that metadata-less case this only prevents the duplicate; the existing expected - // record is left as-is and keeps showing as unspent. Evolving it straight to a consumed - // state from this consumption event is a deferred follow-up, and skipping matches a - // client without erased-note recovery, which never recovers this note either. - if self.input_notes_by_id.contains_key(¬e_id) + // For a metadata-less expected note this only prevents a duplicate record; it is left + // as-is and keeps showing as unspent. Evolving it straight to a consumed state from this + // consumption event is a deferred follow-up, and skipping matches a client without + // erased-note recovery, which never recovers this note either. + if self.input_notes.contains_key(&header.details_commitment()) || self.output_notes.contains_key(¬e_id) - || self.expected_note_matching(note_id, header.metadata()).is_some() { return; } @@ -859,6 +855,7 @@ mod tests { use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; use miden_protocol::note::{ + Note, NoteAssets, NoteAttachments, NoteDetails, @@ -872,7 +869,7 @@ mod tests { PartialNoteMetadata, }; use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; - use miden_protocol::transaction::{InputNoteCommitment, TransactionId}; + use miden_protocol::transaction::{InputNote, InputNoteCommitment, TransactionId}; use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_protocol::{Felt, Word, ZERO}; use miden_standards::note::StandardNote; @@ -988,9 +985,10 @@ mod tests { #[test] fn header_only_record_carries_the_transaction_nullifier() { - // A header-only erased record stores placeholder details, so its nullifier cannot be - // recomputed from them. It must instead carry the nullifier from the consuming - // transaction's input commitment, unchanged and surviving a serialization round-trip. + // A header-only erased record stores placeholder details, so its identity and nullifier + // cannot be derived from them. The details commitment and nullifier must come from the + // header and the consuming transaction's input commitment, unchanged and surviving a + // serialization round-trip. let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap(); let details = note_details(20); let metadata = note_metadata(sender); @@ -1001,6 +999,8 @@ mod tests { InputNoteRecord::from_header(&header, nullifier, BlockNumber::from(7u32), Some(sender)); assert_eq!(record.nullifier(), Some(nullifier)); + assert_eq!(record.details_commitment(), header.details_commitment()); + assert_eq!(record.id(), Some(header.id())); assert_ne!( record.nullifier(), Some(Nullifier::from_details_and_metadata(record.details(), &metadata)), @@ -1009,6 +1009,28 @@ mod tests { let restored = InputNoteRecord::read_from_bytes(&record.to_bytes()).unwrap(); assert_eq!(restored.nullifier(), Some(nullifier)); + assert_eq!(restored.details_commitment(), header.details_commitment()); + } + + #[test] + fn header_only_record_rejects_details_dependent_conversions() { + // A header-only record carries only placeholder details, so converting it into a + // `Note`, `InputNote` or `NoteDetails` would fabricate a note that never existed. All + // three conversions must fail instead. + let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap(); + let details = note_details(21); + let metadata = note_metadata(sender); + let header = NoteHeader::new(details.commitment(), metadata); + let nullifier = Nullifier::from_details_and_metadata(&details, &metadata); + + let record = + InputNoteRecord::from_header(&header, nullifier, BlockNumber::from(7u32), Some(sender)); + + let as_note: Result = record.clone().try_into(); + assert!(as_note.is_err()); + let as_input_note: Result = record.clone().try_into(); + assert!(as_input_note.is_err()); + assert!(NoteDetails::try_from(record).is_err()); } #[test] diff --git a/crates/rust-client/src/store/note_record/input_note_record/mod.rs b/crates/rust-client/src/store/note_record/input_note_record/mod.rs index 60ef279afe..d7fee8dee8 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/mod.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/mod.rs @@ -48,10 +48,11 @@ pub use states::{ /// Builds placeholder [`NoteDetails`] for a header-only record (see /// [`InputNoteRecord::from_header`]). The bytes are meaningless and must not be inspected -/// ([`InputNoteRecord::has_details`] returns `false` for these records); the authoritative note id -/// and metadata live in the [`InputNoteState::ConsumedExternalErased`] state. The note id is folded -/// into the placeholder's serial number so the resulting details commitment, which is the store's -/// primary key for input notes, stays unique per note. +/// ([`InputNoteRecord::has_details`] returns `false` for these records); the authoritative +/// identity (details commitment), nullifier and metadata live in the +/// [`InputNoteState::ConsumedExternalErased`] state, and the placeholder never contributes to the +/// record's identity. The note id is folded into the placeholder's serial number so the +/// placeholder bytes stay unique per note. fn placeholder_details(note_id: NoteId) -> NoteDetails { let assets = NoteAssets::new(vec![]).expect("empty assets are valid"); let storage = NoteStorage::new(vec![]).expect("empty storage is valid"); @@ -99,13 +100,11 @@ impl InputNoteRecord { } /// Creates a header-only record for a note consumed as an unauthenticated input (typically an - /// erased note) that the client only knows via its - /// [`NoteHeader`]. The record is placed in the - /// [`InputNoteState::ConsumedExternalErased`] state, which carries the authoritative note id, - /// nullifier and metadata; the `details` field is a placeholder (see `placeholder_details`) and - /// [`InputNoteRecord::has_details`] returns `false`. The `nullifier` is the one carried by the - /// consuming transaction's input commitment, since it cannot be recomputed from the placeholder - /// details. + /// erased note) that the client only knows via its [`NoteHeader`]. The record is placed in + /// the [`InputNoteState::ConsumedExternalErased`] state, which carries the authoritative + /// details commitment, nullifier and metadata; the `details` field is a placeholder (see + /// `placeholder_details`) and [`InputNoteRecord::has_details`] returns `false`. The + /// `nullifier` must be the one carried by the consuming transaction's input commitment. pub fn from_header( header: &NoteHeader, nullifier: Nullifier, @@ -113,7 +112,7 @@ impl InputNoteRecord { consumer_account: Option, ) -> InputNoteRecord { let state = ConsumedExternalErasedNoteState { - note_id: header.id(), + details_commitment: header.details_commitment(), nullifier, metadata: *header.metadata(), nullifier_block_height, @@ -131,19 +130,14 @@ impl InputNoteRecord { // PUBLIC ACCESSORS // ================================================================================================ - /// Returns the input note ID. For header-only records (state - /// [`InputNoteState::ConsumedExternalErased`]) this is read from the state; otherwise it is - /// computed by combining the details commitment with the note metadata, returning `None` when - /// the current state has no metadata (e.g. an expected note imported from bare - /// `NoteFile::NoteDetails`, or a `ConsumedExternal` note whose prior state carried no - /// metadata). Use [`Self::details_commitment`] when a stable identifier is needed in those - /// cases. + /// Returns the input note ID, computed by combining the details commitment with the note + /// metadata. Returns `None` when the current state has no metadata (e.g. an expected note + /// imported from bare `NoteFile::NoteDetails`, or a `ConsumedExternal` note whose prior state + /// carried no metadata). Use [`Self::details_commitment`] when a stable identifier is needed + /// in those cases. pub fn id(&self) -> Option { - if let InputNoteState::ConsumedExternalErased(s) = &self.state { - return Some(s.note_id); - } let metadata = self.metadata()?; - Some(NoteId::new(self.details.commitment(), metadata)) + Some(NoteId::new(self.details_commitment(), metadata)) } /// Returns `true` when the record carries authoritative note details. Returns `false` for @@ -154,8 +148,13 @@ impl InputNoteRecord { } /// Returns the commitment to the note's details (recipient + assets), independent of - /// note metadata. + /// note metadata. For header-only records (state + /// [`InputNoteState::ConsumedExternalErased`]) this is read from the state, since it cannot + /// be computed from the placeholder details. pub fn details_commitment(&self) -> NoteDetailsCommitment { + if let InputNoteState::ConsumedExternalErased(s) = &self.state { + return s.details_commitment; + } self.details.commitment() } @@ -166,8 +165,7 @@ impl InputNoteRecord { /// Returns the note's commitment, if the record contains the [`NoteMetadata`]. pub fn commitment(&self) -> Option { - self.metadata() - .map(|metadata| NoteId::new(self.details.commitment(), metadata).as_word()) + self.id().map(|id| id.as_word()) } /// Returns the note's assets. @@ -470,6 +468,11 @@ impl TryInto for InputNoteRecord { type Error = NoteRecordError; fn try_into(self) -> Result { + if !self.has_details() { + return Err(NoteRecordError::ConversionError( + "Input Note Record does not contain note details".to_string(), + )); + } match (self.metadata(), self.inclusion_proof()) { (Some(metadata), Some(inclusion_proof)) => Ok(InputNote::authenticated( Note::with_attachments( @@ -497,17 +500,7 @@ impl TryInto for InputNoteRecord { type Error = NoteRecordError; fn try_into(self) -> Result { - match self.metadata() { - Some(metadata) => Ok(Note::with_attachments( - self.details.assets().clone(), - *metadata.partial_metadata(), - self.details.recipient().clone(), - self.attachments.clone(), - )), - None => Err(NoteRecordError::ConversionError( - "Input Note Record does not contain metadata".to_string(), - )), - } + (&self).try_into() } } @@ -515,6 +508,11 @@ impl TryInto for &InputNoteRecord { type Error = NoteRecordError; fn try_into(self) -> Result { + if !self.has_details() { + return Err(NoteRecordError::ConversionError( + "Input Note Record does not contain note details".to_string(), + )); + } match self.metadata() { Some(metadata) => Ok(Note::with_attachments( self.details.assets().clone(), @@ -529,8 +527,15 @@ impl TryInto for &InputNoteRecord { } } -impl From for NoteDetails { - fn from(value: InputNoteRecord) -> Self { - value.details +impl TryFrom for NoteDetails { + type Error = NoteRecordError; + + fn try_from(value: InputNoteRecord) -> Result { + if !value.has_details() { + return Err(NoteRecordError::ConversionError( + "Input Note Record does not contain note details".to_string(), + )); + } + Ok(value.details) } } diff --git a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs index e125499538..c6b17f8ca4 100644 --- a/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs +++ b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs @@ -2,7 +2,13 @@ use alloc::string::ToString; use miden_protocol::account::AccountId; use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::note::{NoteId, NoteInclusionProof, NoteMetadata, Nullifier}; +use miden_protocol::note::{ + NoteDetailsCommitment, + NoteId, + NoteInclusionProof, + NoteMetadata, + Nullifier, +}; use miden_protocol::transaction::TransactionId; use super::{InputNoteState, NoteStateHandler}; @@ -12,17 +18,14 @@ use crate::store::NoteRecordError; /// /// A record enters this state when a tracked account consumes a note as an unauthenticated input /// (typically an erased note, created and consumed in the same batch) whose full details the client -/// never held, only the note header carried in the consuming transaction. With no authoritative -/// [`miden_protocol::note::NoteDetails`], the note id cannot be derived the usual way (from the -/// details commitment and metadata), so it is stored in the state directly. +/// never held, only the note header carried in the consuming transaction. The record's `details` +/// field is a placeholder for this state, so the authoritative identity (the header's details +/// commitment) and the nullifier are stored in the state directly. #[derive(Clone, Debug, PartialEq)] pub struct ConsumedExternalErasedNoteState { - /// The note id, stored directly. Unlike full records, it cannot be derived from the record's - /// details, which are a placeholder for this state. - pub note_id: NoteId, - /// The note nullifier, stored directly for the same reason as `note_id`. It cannot be - /// recomputed from the placeholder details, so it is taken from the consuming - /// transaction's input commitment. + /// The commitment to the note's details, taken from the note header. + pub details_commitment: NoteDetailsCommitment, + /// The note nullifier, taken from the consuming transaction's input commitment. pub nullifier: Nullifier, /// Metadata associated with the note, including sender, note type, tag and other additional /// information. @@ -95,7 +98,7 @@ impl NoteStateHandler for ConsumedExternalErasedNoteState { impl miden_tx::utils::serde::Serializable for ConsumedExternalErasedNoteState { fn write_into(&self, target: &mut W) { - self.note_id.write_into(target); + self.details_commitment.write_into(target); self.nullifier.write_into(target); self.metadata.write_into(target); self.nullifier_block_height.write_into(target); @@ -108,14 +111,14 @@ impl miden_tx::utils::serde::Deserializable for ConsumedExternalErasedNoteState fn read_from( source: &mut R, ) -> Result { - let note_id = NoteId::read_from(source)?; + let details_commitment = NoteDetailsCommitment::read_from(source)?; let nullifier = Nullifier::read_from(source)?; let metadata = NoteMetadata::read_from(source)?; let nullifier_block_height = BlockNumber::read_from(source)?; let consumer_account = Option::::read_from(source)?; let consumed_tx_order = Option::::read_from(source)?; Ok(ConsumedExternalErasedNoteState { - note_id, + details_commitment, nullifier, metadata, nullifier_block_height, diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 6d3e8cca6d..b6fbb50d7c 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -208,7 +208,7 @@ async fn get_input_note() { let note: InputNoteRecord = original_note.clone().into(); client .import_notes(&[NoteFile::NoteDetails { - details: note.into(), + details: note.try_into().expect("note record carries full details"), tag: None, after_block_num: 0.into(), }]) From 7d68d5fe6a991eb4d85421bb2bc253c6188e29fc Mon Sep 17 00:00:00 2001 From: JereSalo Date: Fri, 17 Jul 2026 17:52:44 -0300 Subject: [PATCH 8/8] refactor(rust-client): clarify sync note-recording method names Rename mark_erased_notes_as_consumed to mark_erased_output_notes_as_consumed and record_consumed_unauthenticated_notes to record_consumed_unauthenticated_input_notes. The two walk different data from the transaction record (erased output notes vs header-bearing input commitments), so the names now make that axis explicit. --- crates/rust-client/src/sync/state_sync.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/rust-client/src/sync/state_sync.rs b/crates/rust-client/src/sync/state_sync.rs index 4909d6bc8c..f5d4bd1e91 100644 --- a/crates/rust-client/src/sync/state_sync.rs +++ b/crates/rust-client/src/sync/state_sync.rs @@ -644,12 +644,12 @@ impl StateSync { .apply_output_note_inclusion_proofs(&transaction.output_notes)?; // Detect output notes erased by same-batch note erasure. - Self::mark_erased_notes_as_consumed(state_sync_update, transaction); + Self::mark_erased_output_notes_as_consumed(state_sync_update, transaction); // Record notes consumed by this tracked account that the client only learns about from // the consuming transaction's unauthenticated input commitments (typically erased notes // it never created or discovered). - Self::record_consumed_unauthenticated_notes(state_sync_update, transaction); + Self::record_consumed_unauthenticated_input_notes(state_sync_update, transaction); } Ok(()) @@ -660,7 +660,7 @@ impl StateSync { /// When a note is created and consumed in the same batch, note erasure removes it from /// the block body. The node reports these as erased output notes in the transaction /// record (note ID only, no inclusion proof). We mark them as consumed. - fn mark_erased_notes_as_consumed( + fn mark_erased_output_notes_as_consumed( state_sync_update: &mut StateSyncUpdate, transaction: &RpcTransactionRecord, ) { @@ -677,7 +677,7 @@ impl StateSync { /// Header-bearing commitments are unauthenticated inputs; the transaction's account is their /// consumer. The note's header travels with the commitment, so the consumption can be recorded /// even when the client never held the note's details. - fn record_consumed_unauthenticated_notes( + fn record_consumed_unauthenticated_input_notes( state_sync_update: &mut StateSyncUpdate, transaction: &RpcTransactionRecord, ) {