diff --git a/CHANGELOG.md b/CHANGELOG.md index c4272a4d18..5e904829fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Breaking Changes +* [BREAKING][behavior][rust] Notes fetched from the Note Transport Layer are screened when their tag matches a tracked account's tag, discarding the ones no tracked account can consume ([#2474](https://github.com/0xMiden/rust-sdk/pull/2474)). * [BREAKING][behavior][rust] A Note Transport Layer failure no longer fails `Client::sync_state`. The error is logged and the chain sync still applies; the transport cursor is left where it was, so the next sync requests the same page again ([#2453](https://github.com/0xMiden/rust-sdk/pull/2453)). * [BREAKING][type][rust] Added the `TransactionRequestError::SwapNoteWithZeroAsset` variant, so exhaustive matches on `TransactionRequestError` must handle it ([#2459](https://github.com/0xMiden/rust-sdk/pull/2459)). * [BREAKING][removal][test] Loose helper functions in `miden_client::testing::common` are now methods on `TestClient`. `TestClient::keystore()` exposes the client's keystore, so `ClientConfig::into_client` and `into_unsynced_client` return just the `TestClient` instead of a client/keystore pair ([#2481](https://github.com/0xMiden/rust-sdk/pull/2481)). diff --git a/crates/rust-client/src/note_transport/mod.rs b/crates/rust-client/src/note_transport/mod.rs index c1f6c7fd1c..ef40994cef 100644 --- a/crates/rust-client/src/note_transport/mod.rs +++ b/crates/rust-client/src/note_transport/mod.rs @@ -438,6 +438,43 @@ where ))) } + /// Screens the transport-delivered notes carrying a tag derived from a tracked account, + /// discarding those that no tracked account can consume. Notes carrying any other tag are kept + /// as delivered. + async fn screen_transport_notes( + &self, + notes: &mut Vec<(Note, Option)>, + ) -> Result<(), ClientError> { + let account_tags = self.tracked_account_tags().await?; + + let notes_to_screen: Vec = notes + .iter() + .filter(|(note, _)| account_tags.contains(¬e.metadata().tag())) + .map(|(note, _)| note.clone()) + .collect(); + let consumable = self.note_screener().get_batch_consumability(¬es_to_screen).await?; + + // Discard the notes whose tag match the tracked accounts but are not consumable. + notes.retain(|(note, _)| { + !account_tags.contains(¬e.metadata().tag()) || consumable.contains_key(¬e.id()) + }); + + Ok(()) + } + + /// Returns the tracked tags that were registered for an account, i.e. derived from its ID. + async fn tracked_account_tags(&self) -> Result, ClientError> { + let tags = self + .store + .get_note_tags() + .await? + .into_iter() + .filter(|record| matches!(record.source, NoteTagSource::Account(_))) + .map(|record| record.tag) + .collect(); + Ok(tags) + } + /// Fetches and returns one batch of notes from the note transport layer for the provided tags /// without applying any update to the store. /// @@ -498,6 +535,11 @@ where notes.push((note, note_info.block_hint)); } + // Screen the transport-delivered notes to discard the ones that are not relevant to the + // accounts tracked by the client. Boxed to avoid a `clippy::large_futures` warning, since + // the sync future is already close to the size limit. + Box::pin(self.screen_transport_notes(&mut notes)).await?; + self.drop_notes_processed_locally(&mut notes).await?; let sync_height = self.get_sync_height().await?; diff --git a/crates/testing/miden-client-tests/src/tests/transport.rs b/crates/testing/miden-client-tests/src/tests/transport.rs index fd203e60a2..252552fd7f 100644 --- a/crates/testing/miden-client-tests/src/tests/transport.rs +++ b/crates/testing/miden-client-tests/src/tests/transport.rs @@ -12,8 +12,9 @@ use miden_client::note::{ NoteExecutionHint, NoteTag, NoteType, + PartialNoteMetadata, }; -use miden_client::note_transport::NoteTransportClient; +use miden_client::note_transport::{NoteTransportClient, NoteTransportCursor}; use miden_client::store::NoteFilter; use miden_client::testing::common::{TestClient, create_test_store_path}; use miden_client::testing::mock::{MockClient, MockRpcApi}; @@ -920,6 +921,81 @@ async fn flush_relay_outbox_retries_failed_relay_without_full_sync() { ); } +/// A note is routed by the tag in its own metadata, not by who can consume it, and an +/// account-target tag only covers the 14 most significant bits of the account id prefix. The +/// transport fetch screens what a tag match delivers, so a client that is offered a note paying +/// someone else discards it instead of storing it. +#[tokio::test] +async fn note_delivered_by_tag_match_is_only_kept_when_a_tracked_account_can_consume_it() { + let mock_node = Arc::new(RwLock::new(MockNoteTransportNode::new())); + let (mut sender, sender_account) = create_test_user_transport(mock_node.clone()).await; + let (mut client, account) = create_test_user_transport(mock_node.clone()).await; + + // Any account that the client does not track serves as the note's target. + let unrelated_account: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap(); + + // A P2ID note only the unrelated account can consume, but carrying the client's account tag. + let note: Note = P2idNote::builder() + .sender(sender_account.id()) + .target(unrelated_account) + .asset(dummy_asset()) + .note_type(NoteType::Private) + .generate_serial_number(sender.rng()) + .build() + .unwrap() + .into(); + // By default the P2ID note is tagged for the target account, so here we manually override the + // note with the client's account tag. + let (assets, _, recipient, attachments) = note.into_parts(); + let account_tag = NoteTag::with_account_target(account.id()); + let metadata = + PartialNoteMetadata::new(sender_account.id(), NoteType::Private).with_tag(account_tag); + let note = Note::with_attachments(assets, metadata, recipient, attachments); + + // The address is not what routes the note: the relay keys off the tag in its header. + let address = Address::new(account.id()) + .with_routing_parameters(RoutingParameters::new(AddressInterface::BasicWallet)); + sender + .send_private_note_with_block_hint(note.clone(), &address, BlockNumber::from(0)) + .await + .unwrap(); + + // Fetch the notes matching the `account_tag` and check the P2ID note was actually committed + let (notes_info, _) = mock_node.read().get_notes(&[account_tag], NoteTransportCursor::init()); + let fetched_note_info = notes_info.first().unwrap(); + assert_eq!(fetched_note_info.header, *note.header()); + + // During the sync, the client retrieves the note from the NTL (since the tag matches its + // account), but the note is discarded because its account cannot consume it. + client.sync_state().await.unwrap(); + let notes = client.get_input_notes(NoteFilter::All).await.unwrap(); + assert!(notes.is_empty(), "a note no tracked account can consume must not be stored"); + + // Now send a note consumable by the account and check the client tracks it + let note: Note = P2idNote::builder() + .sender(sender_account.id()) + .target(account.id()) + .asset(dummy_asset()) + .note_type(NoteType::Private) + .generate_serial_number(sender.rng()) + .build() + .unwrap() + .into(); + + let recipient_address = Address::new(account.id()) + .with_routing_parameters(RoutingParameters::new(AddressInterface::BasicWallet)); + sender + .send_private_note_with_block_hint(note, &recipient_address, BlockNumber::from(0)) + .await + .unwrap(); + + // The client now will track the note during the sync because this time the note is consumable + // by the tracked account + client.sync_state().await.unwrap(); + let notes = client.get_input_notes(NoteFilter::All).await.unwrap(); + assert_eq!(notes.len(), 1, "a note the tracked account can consume must be stored"); +} + /// A relay that keeps failing must not block `sync_state`. The outbox flush runs at the start of /// the transport step; if its error propagated, a single undeliverable note would wedge every /// subsequent sync. The entry must stay in the outbox for later retry while the sync itself