diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da69c204f..0ea1f2ab80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ * [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 + +* [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)). ### Changes diff --git a/bin/integration-tests/src/tests/client.rs b/bin/integration-tests/src/tests/client.rs index d1a2fb6f22..0a8b64ba27 100644 --- a/bin/integration-tests/src/tests/client.rs +++ b/bin/integration-tests/src/tests/client.rs @@ -283,7 +283,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::ExpectedNote { - details: note.clone().into(), + details: note.clone().try_into().expect("note record carries full details"), sync_hint: NoteSyncHint::new( client_1.get_sync_height().await.unwrap(), 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::ExpectedNote { - details: note.clone().into(), + details: note.clone().try_into().expect("note record carries full details"), sync_hint: NoteSyncHint::new(0.into(), note.metadata().unwrap().tag()), }]) .await?[0]; @@ -412,7 +412,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::ExpectedNote { - details: note.clone().into(), + details: note.clone().try_into().expect("note record carries full details"), sync_hint: NoteSyncHint::new(block_height_before, note.metadata().unwrap().tag()), }]) .await?[0]; @@ -431,7 +431,7 @@ pub async fn test_import_expected_notes_from_the_past_as_committed( assert!( client_2 .import_notes(&[NoteFile::ExpectedNote { - details: note.clone().into(), + details: note.clone().try_into().expect("note record carries full details"), sync_hint: NoteSyncHint::new(block_height_before, note.metadata().unwrap().tag()), }]) .await? diff --git a/bin/integration-tests/src/tests/onchain.rs b/bin/integration-tests/src/tests/onchain.rs index 5ef12966c9..04f1f2bf2d 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, AssetAmount, FungibleAsset}; use miden_client::auth::RPO_FALCON_SCHEME_ID; use miden_client::keystore::Keystore; @@ -13,6 +13,7 @@ use miden_client::note::{ NoteAttachmentScheme, NoteAttachments, NoteFile, + NoteId, NoteType, P2idNote, }; @@ -724,6 +725,142 @@ 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, +/// 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<()> { + 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/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 2c3187a447..b2e90de5fd 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, @@ -513,6 +514,46 @@ impl NoteUpdateTracker { Ok(()) } + /// Inserts a header-only consumed input note for a tracked account's transaction. + /// + /// 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, + consumer: AccountId, + block_num: BlockNumber, + ) { + let Some(header) = commitment.header() else { + return; + }; + let note_id = header.id(); + // 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 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) + { + return; + } + + // 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, commitment.nullifier(), 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 @@ -814,18 +855,21 @@ mod tests { use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; use miden_protocol::note::{ + Note, NoteAssets, NoteAttachments, NoteDetails, + NoteHeader, NoteId, NoteMetadata, NoteRecipient, NoteStorage, NoteType, + Nullifier, PartialNoteMetadata, }; use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; - use miden_protocol::transaction::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; @@ -939,6 +983,76 @@ 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 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); + 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_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)), + "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)); + 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] + 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(); diff --git a/crates/rust-client/src/rpc/domain/transaction.rs b/crates/rust-client/src/rpc/domain/transaction.rs index 43147095c9..b17123a259 100644 --- a/crates/rust-client/src/rpc/domain/transaction.rs +++ b/crates/rust-client/src/rpc/domain/transaction.rs @@ -130,7 +130,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 (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) + .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 da5cd5120f..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 @@ -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,20 @@ 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 +/// 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"); + let recipient = NoteRecipient::new(note_id.as_word(), P2idNote::script(), storage); + NoteDetails::new(assets, recipient) +} + // INPUT NOTE RECORD // ================================================================================================ @@ -79,21 +99,62 @@ impl InputNoteRecord { InputNoteRecord { details, attachments, created_at, state } } + /// 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 + /// 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, + nullifier_block_height: BlockNumber, + consumer_account: Option, + ) -> InputNoteRecord { + let state = ConsumedExternalErasedNoteState { + details_commitment: header.details_commitment(), + nullifier, + 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`). 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 { 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 + /// 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. + /// 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() } @@ -104,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. @@ -142,8 +202,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)) } @@ -183,6 +248,7 @@ impl InputNoteRecord { Some(s.submission_data.consumer_account) }, InputNoteState::ConsumedExternal(s) => s.consumer_account, + InputNoteState::ConsumedExternalErased(s) => s.consumer_account, _ => None, } } @@ -203,6 +269,7 @@ impl InputNoteRecord { matches!( self.state, InputNoteState::ConsumedExternal { .. } + | InputNoteState::ConsumedExternalErased { .. } | InputNoteState::ConsumedAuthenticatedLocal { .. } | InputNoteState::ConsumedUnauthenticatedLocal { .. } ) @@ -401,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( @@ -428,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() } } @@ -446,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(), @@ -460,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 new file mode 100644 index 0000000000..c6b17f8ca4 --- /dev/null +++ b/crates/rust-client/src/store/note_record/input_note_record/states/consumed_external_erased.rs @@ -0,0 +1,135 @@ +use alloc::string::ToString; + +use miden_protocol::account::AccountId; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::note::{ + NoteDetailsCommitment, + NoteId, + NoteInclusionProof, + NoteMetadata, + Nullifier, +}; +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 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. 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 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. + 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.details_commitment.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); + self.consumed_tx_order.write_into(target); + } +} + +impl miden_tx::utils::serde::Deserializable for ConsumedExternalErasedNoteState { + fn read_from( + source: &mut R, + ) -> Result { + 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 { + details_commitment, + nullifier, + 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/mod.rs b/crates/rust-client/src/store/note_record/input_note_record/states/mod.rs index 6f87162edc..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 @@ -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 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), } 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 (header only, at block {} by tracked account {})", + state.nullifier_block_height, account + ) + } else { + 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 7aed7582c2..f5d4bd1e91 100644 --- a/crates/rust-client/src/sync/state_sync.rs +++ b/crates/rust-client/src/sync/state_sync.rs @@ -644,7 +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_input_notes(state_sync_update, transaction); } Ok(()) @@ -655,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, ) { @@ -667,6 +672,25 @@ impl StateSync { } } + /// 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_input_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 6db1c20395..500d17ed58 100644 --- a/crates/sqlite-store/src/note/filters.rs +++ b/crates/sqlite-store/src/note/filters.rs @@ -170,10 +170,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 c12adf5c17..318728be2b 100644 --- a/crates/sqlite-store/src/note/mod.rs +++ b/crates/sqlite-store/src/note/mod.rs @@ -228,6 +228,7 @@ impl SqliteStore { Value::from(InputNoteState::STATE_CONSUMED_AUTHENTICATED_LOCAL), Value::from(InputNoteState::STATE_CONSUMED_UNAUTHENTICATED_LOCAL), Value::from(InputNoteState::STATE_CONSUMED_EXTERNAL), + Value::from(InputNoteState::STATE_CONSUMED_EXTERNAL_ERASED), ]); conn.prepare(QUERY) .into_store_error()? @@ -406,14 +407,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_bytes(); - // `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_bytes()); - let nullifier = note.metadata().map(|metadata| { - miden_client::note::Nullifier::from_details_and_metadata(note.details(), metadata) - .to_bytes() - }); + let nullifier = note.nullifier().map(|nullifier| nullifier.to_bytes()); 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 a1ad6f1faf..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::{ @@ -22,7 +23,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, @@ -117,9 +118,103 @@ 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 nullifier = Nullifier::from_details_and_metadata(&details, &metadata); + + let mut record = InputNoteRecord::from_header( + &header, + nullifier, + 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. 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] +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 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); + 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 385f47d2e3..11e2f44ee4 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -199,7 +199,7 @@ async fn get_input_note() { let note: InputNoteRecord = original_note.clone().into(); client .import_notes(&[NoteFile::ExpectedNote { - details: note.clone().into(), + details: note.clone().try_into().expect("note record carries full details"), sync_hint: NoteSyncHint::new(0.into(), note.metadata().unwrap().tag()), }]) .await