diff --git a/crates/rust-client/src/note/import.rs b/crates/rust-client/src/note/import.rs index 1f7e43803d..3e18579802 100644 --- a/crates/rust-client/src/note/import.rs +++ b/crates/rust-client/src/note/import.rs @@ -21,6 +21,7 @@ use miden_protocol::note::{ NoteId, NoteInclusionProof, NoteTag, + Nullifier, }; use miden_standards::note::NoteFile; use miden_tx::auth::TransactionAuthenticator; @@ -355,6 +356,37 @@ where let mut committed_notes_data = self.sync_expected_notes(lowest_request_block, note_requests).await?; + // Spends that happened at or below the current sync height are invisible to the forward + // nullifier sync, which only scans from `sync_height + 1` onwards and never rewinds. Look + // those up here, from the earliest imported note's inclusion block, so a note that was + // already consumed before this client learned about it is not imported as consumable. + // Spends above the sync height are left to the next sync, which also attributes the + // consuming account when it is tracked locally. + let sync_height = self.get_sync_height().await?; + let details_by_commitment: BTreeMap = requested_notes + .iter() + .map(|(_, details, ..)| (details.commitment(), details.clone())) + .collect(); + let mut nullifier_requests = BTreeSet::new(); + let mut lowest_nullifier_block: BlockNumber = u32::MAX.into(); + for (commitment, synced_note) in &committed_notes_data { + let Some(details) = details_by_commitment.get(commitment) else { + continue; + }; + nullifier_requests.insert(Nullifier::from_details_and_metadata( + details, + synced_note.committed.metadata(), + )); + lowest_nullifier_block = lowest_nullifier_block.min(synced_note.committed.block_num()); + } + let nullifier_commit_heights = if nullifier_requests.is_empty() { + BTreeMap::new() + } else { + self.rpc_api + .get_nullifier_commit_heights(nullifier_requests, lowest_nullifier_block) + .await? + }; + let mut note_records = vec![]; let mut partial_mmr = self.get_current_partial_mmr().await?; @@ -395,6 +427,15 @@ where // `block_header_received` transitions the record's state, so it must always run. note_changed |= note_record.block_header_received(&block_header)?; + let nullifier = Nullifier::from_details_and_metadata(note_record.details(), &metadata); + if let Some(nullifier_block_height) = + nullifier_commit_heights.get(&nullifier).and_then(|height| *height) + && nullifier_block_height <= sync_height + { + note_changed |= + note_record.consumed_externally(nullifier, nullifier_block_height, None)?; + } + // Once committed, the note no longer needs its expected-note tag. if note_changed { self.store diff --git a/crates/testing/miden-client-tests/src/tests/transport.rs b/crates/testing/miden-client-tests/src/tests/transport.rs index 56b54d9e6a..f3518b26a8 100644 --- a/crates/testing/miden-client-tests/src/tests/transport.rs +++ b/crates/testing/miden-client-tests/src/tests/transport.rs @@ -14,7 +14,7 @@ use miden_client::note::{ NoteType, }; use miden_client::note_transport::NoteTransportClient; -use miden_client::store::NoteFilter; +use miden_client::store::{InputNoteState, NoteFilter}; use miden_client::testing::common::create_test_store_path; use miden_client::testing::mock::{MockClient, MockRpcApi}; use miden_client::testing::note_transport::{ @@ -180,7 +180,7 @@ async fn unavailable_attachments_do_not_fail_sync() { // path: the note advertises attachment content the node cannot serve, and the sync succeeds // by skipping the note. let (mut client, private_note, mock_transport_node) = - committed_private_note_recipient(0, true).await; + committed_private_note_recipient(0, true, false).await; assert!(client.get_input_notes(NoteFilter::All).await.unwrap().is_empty()); // Receiving the same note over the NTL imports it, but it stays expected rather than being @@ -812,7 +812,7 @@ async fn fetch_private_notes_uses_sender_provided_after_block_num() { // Commit the note at block 1, then advance far enough that the 20-block fallback window // (sync_height - 20) starts well above block 1 and would miss it. let (mut client, private_note, mock_transport_node) = - committed_private_note_recipient(30, false).await; + committed_private_note_recipient(30, false, false).await; let sync_height = client.get_sync_height().await.unwrap(); assert!( @@ -844,7 +844,7 @@ async fn fetch_private_notes_uses_sender_provided_after_block_num() { #[tokio::test] async fn fetch_private_notes_without_floor_falls_back_to_lookback_window() { let (mut client, private_note, mock_transport_node) = - committed_private_note_recipient(30, false).await; + committed_private_note_recipient(30, false, false).await; // Deliver the note WITHOUT a floor: the recipient must rely on the lookback heuristic. let details_bytes = NoteDetails::from(private_note.clone()).to_bytes(); @@ -868,6 +868,30 @@ async fn fetch_private_notes_without_floor_falls_back_to_lookback_window() { ); } +/// A note already consumed before first delivery through NTL must not remain consumable. +#[tokio::test] +async fn fetch_private_notes_marks_historically_consumed_note() { + let (mut client, private_note, mock_transport_node) = + committed_private_note_recipient(0, false, true).await; + + let details_bytes = NoteDetails::from(private_note.clone()).to_bytes(); + mock_transport_node.write().add_note(*private_note.header(), details_bytes); + + client.sync_state().await.unwrap(); + + let notes = client + .get_input_notes(NoteFilter::DetailsCommitments(vec![private_note.details_commitment()])) + .await + .unwrap(); + assert_eq!(notes.len(), 1, "the NTL-delivered note should be imported"); + assert!(notes[0].is_consumed(), "historically spent note must not remain consumable"); + assert!( + matches!(notes[0].state(), InputNoteState::ConsumedExternal(..)), + "expected ConsumedExternal, got {}", + notes[0].state() + ); +} + // HELPERS // ================================================================================================ @@ -947,6 +971,7 @@ fn private_note_with_tag(account: AccountId, tag: NoteTag, seed: u64) -> Note { async fn committed_private_note_recipient( blocks_past_commitment: u32, with_unserved_attachment: bool, + consume_before_sync: bool, ) -> (MockClient, Note, Arc>) { let mut mock_chain_builder = MockChainBuilder::new(); let mock_account = mock_chain_builder @@ -985,6 +1010,21 @@ async fn committed_private_note_recipient( mock_chain.add_pending_executed_transaction(&tx).unwrap(); mock_chain.prove_next_block().unwrap(); + if consume_before_sync { + let consume_tx = Box::pin( + mock_chain + .build_transaction(MockTransactionInput::AccountId(mock_account.id())) + .unauthenticated_input_note(private_note.clone()) + .build() + .unwrap() + .execute(), + ) + .await + .unwrap(); + mock_chain.add_pending_executed_transaction(&consume_tx).unwrap(); + mock_chain.prove_next_block().unwrap(); + } + // Advance the chain past the note's commitment block. for _ in 0..blocks_past_commitment { mock_chain.prove_next_block().unwrap();