Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions crates/rust-client/src/note/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use miden_protocol::note::{
NoteId,
NoteInclusionProof,
NoteTag,
Nullifier,
};
use miden_standards::note::NoteFile;
use miden_tx::auth::TransactionAuthenticator;
Expand Down Expand Up @@ -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<NoteDetailsCommitment, NoteDetails> = 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?;

Expand Down Expand Up @@ -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
Expand Down
48 changes: 44 additions & 4 deletions crates/testing/miden-client-tests/src/tests/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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();
Expand All @@ -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
// ================================================================================================

Expand Down Expand Up @@ -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<FilesystemKeyStore>, Note, Arc<RwLock<MockNoteTransportNode>>) {
let mut mock_chain_builder = MockChainBuilder::new();
let mock_account = mock_chain_builder
Expand Down Expand Up @@ -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();
Expand Down