Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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][rpc] The `GetAccount` response no longer carries one SMT opening per requested storage map key. A slot queried with specific keys now comes back as a single partial SMT covering all of them, alongside the original unhashed keys, so the client requires a node that speaks this format ([#2360](https://github.com/0xMiden/rust-sdk/issues/2360)).
* [BREAKING][type][rust] `StorageMapEntries::EntriesWithProofs(Vec<SmtProof>)` is replaced by `StorageMapEntries::PartialMap { map_keys, partial_smt }`, which carries the values only inside the tree: read one by hashing its raw key and calling `PartialSmt::get_value`. The enum also gained a `LimitExceeded` variant and `AccountStorageMapDetails::too_many_entries` was removed in its favor ([#2360](https://github.com/0xMiden/rust-sdk/issues/2360)).
* [BREAKING][type][rust] `SyncedNote` splits the content it carries into two fields, `details: Option<NoteDetails>` and `attachments: NoteAttachments`, replacing the previous `content: Option<ResolvedNoteContent>`; `SyncedNote::new` takes them as separate arguments. `ResolvedNoteContent` is removed. Attachments are no longer optional, a note whose metadata advertises none carries an empty set, so "no attachments" and "attachments not resolved" are no longer the same value ([#2431](https://github.com/0xMiden/rust-sdk/pull/2431)).
Expand Down
43 changes: 43 additions & 0 deletions crates/rust-client/src/note_transport/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,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<BlockNumber>)>,
) -> Result<(), ClientError> {
let account_tags = self.tracked_account_tags().await?;

let notes_to_screen: Vec<Note> = notes
.iter()
.filter(|(note, _)| account_tags.contains(&note.metadata().tag()))
.map(|(note, _)| note.clone())
.collect();
let consumable = self.note_screener().get_batch_consumability(&notes_to_screen).await?;

// Discard the notes whose tag match the tracked accounts but are not consumable.
notes.retain(|(note, _)| {
!account_tags.contains(&note.metadata().tag()) || consumable.contains_key(&note.id())
});
Comment on lines +440 to +456

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not using the block number at all, we can just change the parameter to Vec<Note>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is that the caller function fetch_note_transport_updates requires to have the note binded to the block number, since it uses it as a hint when fetching the notes from the node, so I think is simpler to keep it that way.


Ok(())
}

/// Returns the tracked tags that were registered for an account, i.e. derived from its ID.
async fn tracked_account_tags(&self) -> Result<BTreeSet<NoteTag>, 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.
///
Expand Down Expand Up @@ -479,6 +516,12 @@ 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?;

let sync_height = self.get_sync_height().await?;
let fallback_after_block_num =
BlockNumber::from(sync_height.as_u32().saturating_sub(NOTE_LOOKBACK_BLOCKS));
Expand Down
78 changes: 77 additions & 1 deletion crates/testing/miden-client-tests/src/tests/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::create_test_store_path;
use miden_client::testing::mock::{MockClient, MockRpcApi};
Expand Down Expand Up @@ -933,6 +934,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
Expand Down