Skip to main content

miden_client/note_transport/
mod.rs

1pub mod errors;
2pub mod generated;
3#[cfg(feature = "tonic")]
4pub mod grpc;
5
6use alloc::boxed::Box;
7use alloc::collections::{BTreeMap, BTreeSet};
8use alloc::string::String;
9use alloc::sync::Arc;
10use alloc::vec::Vec;
11
12use miden_protocol::address::Address;
13use miden_protocol::block::BlockNumber;
14use miden_protocol::note::{
15    Note,
16    NoteDetails,
17    NoteDetailsCommitment,
18    NoteHeader,
19    NoteId,
20    NoteInclusionProof,
21    NoteTag,
22};
23use miden_protocol::utils::serde::Serializable;
24use miden_tx::auth::TransactionAuthenticator;
25use miden_tx::utils::serde::{
26    ByteReader,
27    ByteWriter,
28    Deserializable,
29    DeserializationError,
30    SliceReader,
31};
32
33pub use self::errors::NoteTransportError;
34use crate::note::{NoteFile, NoteSyncHint};
35use crate::store::{NoteFilter, SettingScope};
36use crate::sync::NoteTagSource;
37use crate::{Client, ClientError};
38
39pub const NOTE_TRANSPORT_MAINNET_ENDPOINT: &str = "https://transport.mainnet.miden.io";
40pub const NOTE_TRANSPORT_TESTNET_ENDPOINT: &str = "https://transport.miden.io";
41pub const NOTE_TRANSPORT_DEVNET_ENDPOINT: &str = "https://transport.devnet.miden.io";
42/// Settings key for the unused aggregate transport cursor.
43#[deprecated(since = "0.17.1", note = "note transport does not read this setting")]
44pub const NOTE_TRANSPORT_CURSOR_STORE_SETTING: &str = "note_transport_cursor";
45/// Legacy settings key for the cursor of each tag. The client migrates this value to
46/// [`NOTE_TRANSPORT_SYNC_STATE_KEY`].
47#[deprecated(
48    since = "0.18.0",
49    note = "note transport stores one cursor under NOTE_TRANSPORT_SYNC_STATE_KEY"
50)]
51pub const NOTE_TRANSPORT_CURSORS_KEY: &str = "note_transport_cursors";
52/// Settings key for the note transport cursor and the tags that it covers.
53pub const NOTE_TRANSPORT_SYNC_STATE_KEY: &str = "note_transport_sync_state";
54
55/// Maximum number of note tags in one transport fetch request. The service rejects larger requests.
56const MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST: usize = 128;
57
58/// Legacy settings key for note transport backfill state.
59#[deprecated(since = "0.17.1", note = "note transport no longer keeps per-tag backfill state")]
60pub const NOTE_TRANSPORT_COVERED_TAGS_KEY: &str = "note_transport_covered_tags";
61
62/// Client note transport methods.
63impl<AUTH> Client<AUTH> {
64    /// Maximum number of note tags in one transport fetch request.
65    pub const MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST: usize = MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST;
66
67    /// Legacy name for [`Self::MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST`].
68    ///
69    /// This value is not a limit on the number of account tags that the client can track.
70    #[deprecated(
71        since = "0.17.1",
72        note = "use MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST; account tags are no longer limited"
73    )]
74    pub const MAX_ACCOUNT_TAGS: usize = Self::MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST;
75
76    /// Check if note transport connection is configured
77    pub fn is_note_transport_enabled(&self) -> bool {
78        self.note_transport_api.is_some()
79    }
80
81    /// Returns the Note Transport client
82    ///
83    /// Errors if the note transport is not configured.
84    pub(crate) fn get_note_transport_api(
85        &self,
86    ) -> Result<Arc<dyn NoteTransportClient>, NoteTransportError> {
87        self.note_transport_api.clone().ok_or(NoteTransportError::Disabled)
88    }
89
90    /// Send a note through the note transport network together with its inclusion proof.
91    ///
92    /// The note will be end-to-end encrypted (unimplemented, currently plaintext) using the
93    /// provided recipient's `address` details. The recipient will be able to retrieve this note
94    /// through the note's [`NoteTag`].
95    ///
96    /// The transport carries the proof through [`NoteTransportClient::send_note_with_proof`]. The
97    /// network verifies `inclusion_proof` against its node before it stores the note and relays the
98    /// exact commitment block to the recipient. The proof exists once the transaction that created
99    /// the note is committed and the sender has synced past it; see
100    /// [`OutputNoteRecord::inclusion_proof`](crate::store::OutputNoteRecord::inclusion_proof).
101    ///
102    /// **Failures.** The client does not keep the note for a later attempt, and no sync sends it
103    /// again. An error means that the note did not reach the network or that the result is not
104    /// known. A send is idempotent by note id: the network stores a note only once, and the
105    /// recipient imports it only once. The caller can therefore send the same note again, now or
106    /// later. For a note that this client created, the output note record keeps the note and its
107    /// inclusion proof, so a later attempt can read both from the store.
108    pub async fn send_private_note_with_proof(
109        &mut self,
110        note: Note,
111        address: &Address,
112        inclusion_proof: NoteInclusionProof,
113    ) -> Result<(), ClientError> {
114        let api = self.get_note_transport_api()?;
115
116        // The address is reserved for end-to-end encryption of the note details:
117        // address.key().encrypt(note.details().to_bytes()).
118        let _ = address;
119
120        api.send_note_with_proof(TransportNote::from(note), inclusion_proof).await?;
121
122        Ok(())
123    }
124
125    /// Loads the note transport sync state.
126    ///
127    /// A store without the state migrates the legacy cursor of each tag. A missing or unreadable
128    /// value returns `None`, so the next fetch reads the retained history of all tags.
129    #[allow(deprecated)]
130    async fn load_note_transport_sync_state(
131        &self,
132    ) -> Result<Option<NoteTransportSyncState>, ClientError> {
133        let get = |key: &str| self.store.get_setting(SettingScope::Client, key.into());
134        let state = if let Some(bytes) = get(NOTE_TRANSPORT_SYNC_STATE_KEY).await? {
135            NoteTransportSyncState::read_from_bytes(&bytes).map(Some)
136        } else if let Some(bytes) = get(NOTE_TRANSPORT_CURSORS_KEY).await? {
137            BTreeMap::<NoteTag, Option<NoteTransportCursor>>::read_from_bytes(&bytes)
138                .map(migrate_tag_cursors)
139        } else {
140            Ok(None)
141        };
142        Ok(state
143            .inspect_err(|err| tracing::warn!(?err, "resetting unreadable note transport state"))
144            .unwrap_or_default())
145    }
146}
147
148impl<AUTH> Client<AUTH>
149where
150    AUTH: TransactionAuthenticator + Sync + 'static,
151{
152    /// Legacy per-sync cap for tag backfills.
153    #[deprecated(since = "0.17.1", note = "note transport no longer performs per-tag backfills")]
154    pub const MAX_BACKFILL_TAGS_PER_SYNC: usize = 64;
155
156    /// Fetch notes for tracked note tags.
157    ///
158    /// The client queries the configured note transport node for all tracked tags. To list tracked
159    /// tags, use [`Client::get_note_tags`]. To add a user-source tag, use [`Client::add_note_tag`].
160    /// Fetched notes are stored in the client store.
161    ///
162    /// A failed request returns an error after the fetched notes are imported, and the next call
163    /// fetches the failed tags again.
164    pub async fn fetch_private_notes(&mut self) -> Result<(), ClientError> {
165        self.ensure_genesis_in_place().await?;
166
167        let mut update = self.fetch_transport_notes_in_chunks().await?;
168        let fetch_error = update.fetch_error.take();
169        self.apply_note_transport_update(update).await?;
170        if let Some(error) = fetch_error {
171            return Err(error);
172        }
173
174        Ok(())
175    }
176
177    /// Screens the transport-delivered notes carrying a tag derived from a tracked account,
178    /// discarding those that no tracked account can consume. Notes carrying any other tag are kept
179    /// as delivered.
180    async fn screen_transport_notes(
181        &self,
182        notes: &mut Vec<(NoteId, Note, Option<BlockNumber>)>,
183    ) -> Result<(), ClientError> {
184        let account_tags = self.tracked_account_tags().await?;
185
186        let notes_to_screen: Vec<Note> = notes
187            .iter()
188            .filter(|(_, note, _)| account_tags.contains(&note.metadata().tag()))
189            .map(|(_, note, _)| note.clone())
190            .collect();
191        let consumable = self.note_screener().get_batch_consumability(&notes_to_screen).await?;
192
193        // Discard the notes whose tag match the tracked accounts but are not consumable.
194        notes.retain(|(_, note, _)| {
195            !account_tags.contains(&note.metadata().tag()) || consumable.contains_key(&note.id())
196        });
197
198        Ok(())
199    }
200
201    /// Returns the tracked tags that were registered for an account, i.e. derived from its ID.
202    async fn tracked_account_tags(&self) -> Result<BTreeSet<NoteTag>, ClientError> {
203        let tags = self
204            .store
205            .get_note_tags()
206            .await?
207            .into_iter()
208            .filter(|record| matches!(record.source, NoteTagSource::Account(_)))
209            .map(|record| record.tag)
210            .collect();
211        Ok(tags)
212    }
213
214    /// Fetches the notes of all tracked tags up to the bound of the first page, with covered tags
215    /// from the stored cursor and the other tags from the first retained note.
216    async fn fetch_transport_notes_in_chunks(
217        &self,
218    ) -> Result<NoteTransportLayerUpdate, ClientError> {
219        let api = self.get_note_transport_api()?;
220        let (start, stored_tags) = self.load_note_transport_sync_state().await?.unzip();
221        let (covered, added): (Vec<_>, Vec<_>) = self
222            .store
223            .get_unique_note_tags()
224            .await?
225            .into_iter()
226            .partition(|tag| stored_tags.as_ref().is_some_and(|tags| tags.contains(tag)));
227
228        let mut update = NoteTransportLayerUpdate::default();
229        let mut notes = Vec::new();
230        let mut sync_to = None;
231        let mut reached = BTreeSet::new();
232        let chunks = covered
233            .chunks(MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST)
234            .map(|chunk| (start, chunk))
235            .chain(added.chunks(MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST).map(|chunk| (None, chunk)));
236        for (start, chunk) in chunks {
237            match fetch_transport_chunk(api.as_ref(), chunk, start, &mut sync_to, &mut notes).await
238            {
239                Ok(()) => reached.extend(chunk),
240                Err(error) => {
241                    update.fetch_error.get_or_insert(error.into());
242                },
243            }
244        }
245
246        // A covered tag that did not reach the bound would skip notes if the cursor moved.
247        if let Some(cursor) = sync_to
248            && covered.iter().all(|tag| reached.contains(tag))
249        {
250            update.sync_state = Some((cursor, reached));
251        }
252        update.id_by_commitment =
253            notes.iter().map(|(id, note, _)| (note.details_commitment(), *id)).collect();
254        update.note_files = self.prepare_transport_notes(notes).await?;
255        Ok(update)
256    }
257
258    /// Screens fetched notes and prepares them for import with one set of store reads.
259    async fn prepare_transport_notes(
260        &self,
261        mut notes: Vec<(NoteId, Note, Option<BlockNumber>)>,
262    ) -> Result<Vec<NoteFile>, ClientError> {
263        // Fallback lookback window, in blocks, used only for notes the transport delivered without
264        // block information. Scanning back from sync height handles the race where a note is
265        // committed on-chain just before the NTL delivers its data. Without it,
266        // check_expected_notes would scan from sync_height forward and miss the already-committed
267        // note. A transport-provided block is deterministic and always preferred.
268        const NOTE_LOOKBACK_BLOCKS: u32 = 20;
269
270        if notes.is_empty() {
271            return Ok(Vec::new());
272        }
273
274        self.drop_notes_resolved_locally(&mut notes).await?;
275
276        // Screen the transport-delivered notes to discard the ones that are not relevant to the
277        // accounts tracked by the client. Boxed to avoid a `clippy::large_futures` warning, since
278        // the sync future is already close to the size limit.
279        Box::pin(self.screen_transport_notes(&mut notes)).await?;
280
281        let sync_height = self.get_sync_height().await?;
282        let fallback_after_block_num =
283            BlockNumber::from(sync_height.as_u32().saturating_sub(NOTE_LOOKBACK_BLOCKS));
284
285        let mut note_files = Vec::with_capacity(notes.len());
286        for (_, note, block_hint) in notes {
287            let tag = note.metadata().tag();
288            // Prefer the transport-provided block, falling back to the lookback window when absent.
289            let after_block_num = block_hint.unwrap_or(fallback_after_block_num);
290            note_files.push(NoteFile::ExpectedNote {
291                details: note.into(),
292                sync_hint: NoteSyncHint::new(after_block_num, tag),
293            });
294        }
295
296        Ok(note_files)
297    }
298
299    /// Fetches the notes the Note Transport Layer holds for the tracked tags.
300    ///
301    /// Fetches the notes of all tracked tags up to one bound. This performs no node call and writes
302    /// nothing, so it can run concurrently with the chain fetch. The caller imports the returned
303    /// files and then saves the sync state. A failed request keeps the notes of earlier pages in
304    /// the returned update.
305    ///
306    /// Returns empty data when note transport is not configured.
307    pub(crate) async fn fetch_note_transport_updates(
308        &self,
309    ) -> Result<NoteTransportLayerUpdate, ClientError> {
310        if !self.is_note_transport_enabled() {
311            return Ok(NoteTransportLayerUpdate::default());
312        }
313
314        self.fetch_transport_notes_in_chunks().await
315    }
316
317    /// Writes everything [`Client::fetch_note_transport_updates`] returned, in two steps:
318    ///
319    /// 1. Imports the fetched notes, which resolves their on-chain state and stores the records.
320    /// 2. Saves the sync state.
321    ///
322    /// The notes are written before the sync state, so a crash between them re-fetches instead of
323    /// skipping notes that were never written.
324    ///
325    /// Returns the ids of the imported notes and the details commitments of the records written.
326    pub(crate) async fn apply_note_transport_update(
327        &mut self,
328        update: NoteTransportLayerUpdate,
329    ) -> Result<(Vec<NoteId>, Vec<NoteDetailsCommitment>), ClientError> {
330        let NoteTransportLayerUpdate {
331            note_files,
332            id_by_commitment,
333            sync_state,
334            fetch_error,
335        } = update;
336
337        let written = self.import_notes(&note_files).await?;
338        let mut imported_ids: Vec<NoteId> = written
339            .iter()
340            .filter_map(|commitment| id_by_commitment.get(commitment).copied())
341            .collect();
342
343        if let Some(state) = sync_state {
344            let key = String::from(NOTE_TRANSPORT_SYNC_STATE_KEY);
345            self.store.set_setting(SettingScope::Client, key, state.to_bytes()).await?;
346        }
347        if let Some(error) = fetch_error {
348            tracing::warn!(
349                ?error,
350                "note transport fetch failed; the next fetch reads the tags again"
351            );
352        }
353
354        imported_ids.sort_unstable();
355        imported_ids.dedup();
356
357        Ok((imported_ids, written))
358    }
359
360    /// Drops deliveries of notes whose local record does not need them.
361    ///
362    /// A note that a local transaction is consuming cannot be overwritten, so its import would
363    /// fail. A note that is already committed or consumed gains nothing from a second import and
364    /// would cost a node request. A tag that starts from the first retained note, or a fetch after
365    /// a failed fetch, can deliver such notes again. Expected, unverified, and invalid notes pass
366    /// through because their records can need a new inclusion proof. A resolved record must match
367    /// the ID in the transport header.
368    async fn drop_notes_resolved_locally(
369        &self,
370        notes: &mut Vec<(NoteId, Note, Option<BlockNumber>)>,
371    ) -> Result<(), ClientError> {
372        let commitments = notes.iter().map(|(_, note, _)| note.details_commitment()).collect();
373        let records: BTreeMap<_, _> = self
374            .get_input_notes(NoteFilter::DetailsCommitments(commitments))
375            .await?
376            .into_iter()
377            .map(|record| (record.details_commitment(), record))
378            .collect();
379        notes.retain(|(id, note, _)| {
380            let Some(record) = records.get(&note.details_commitment()) else {
381                return true;
382            };
383            if record.is_processing() {
384                tracing::warn!(%id, "skipping delivery of a note being consumed locally");
385                return false;
386            }
387            !((record.is_committed() || record.is_consumed()) && record.id() == Some(*id))
388        });
389        Ok(())
390    }
391}
392
393// NOTE TRANSPORT FETCH
394// ================================================================================================
395
396/// What the note transport fetch returned, before anything is written.
397///
398/// Built by [`Client::fetch_note_transport_updates`] and consumed by
399/// [`Client::apply_note_transport_update`].
400#[derive(Default)]
401pub(crate) struct NoteTransportLayerUpdate {
402    /// Notes to import.
403    note_files: Vec<NoteFile>,
404    /// Note ids by details commitment, taken from the note headers the transport returned. Used to
405    /// resolve the written records back to ids.
406    id_by_commitment: BTreeMap<NoteDetailsCommitment, NoteId>,
407    /// New sync state. `None` keeps the stored state.
408    sync_state: Option<NoteTransportSyncState>,
409    /// First request error. Successful pages remain available for import.
410    pub(crate) fetch_error: Option<ClientError>,
411}
412
413/// Stored cursor and the tags that received all notes up to it.
414type NoteTransportSyncState = (NoteTransportCursor, BTreeSet<NoteTag>);
415
416/// Migrates the legacy cursor of each tag to the lowest cursor.
417///
418/// Sequences from different service databases do not compare, so cursors with different nonces
419/// migrate to no state. A tag without a cursor is not covered.
420fn migrate_tag_cursors(
421    cursors: BTreeMap<NoteTag, Option<NoteTransportCursor>>,
422) -> Option<NoteTransportSyncState> {
423    let cursors: BTreeMap<_, _> =
424        cursors.into_iter().filter_map(|(tag, cursor)| Some((tag, cursor?))).collect();
425    let cursor = *cursors.values().min_by_key(|cursor| cursor.sequence)?;
426    cursors
427        .values()
428        .all(|other| other.nonce == cursor.nonce)
429        .then(|| (cursor, cursors.into_keys().collect()))
430}
431
432/// Fetches the pages of one chunk of tags after `start` and up to the bound.
433///
434/// The first successful page sets the bound when `sync_to` has none.
435async fn fetch_transport_chunk(
436    api: &dyn NoteTransportClient,
437    tags: &[NoteTag],
438    start: Option<NoteTransportCursor>,
439    sync_to: &mut Option<NoteTransportCursor>,
440    notes: &mut Vec<(NoteId, Note, Option<BlockNumber>)>,
441) -> Result<(), NoteTransportError> {
442    let mut cursor = start;
443    // Validation requires each page to move the cursor toward the fixed bound, so the loop ends.
444    loop {
445        let page = api.fetch_notes_page(tags, cursor, *sync_to).await?;
446        notes.extend(validate_transport_page(&page, tags, cursor, *sync_to)?);
447        sync_to.get_or_insert(page.sync_to);
448        if !page.has_more {
449            return Ok(());
450        }
451        cursor = Some(page.cursor);
452    }
453}
454
455/// Validates a complete page before its notes or cursor can advance local progress, and returns its
456/// notes.
457fn validate_transport_page(
458    page: &NoteTransportPage,
459    tags: &[NoteTag],
460    request_cursor: Option<NoteTransportCursor>,
461    request_sync_to: Option<NoteTransportCursor>,
462) -> Result<Vec<(NoteId, Note, Option<BlockNumber>)>, NoteTransportError> {
463    let NoteTransportCursor { nonce, sequence } = page.cursor;
464    if request_sync_to.is_some_and(|bound| bound != page.sync_to)
465        || nonce != page.sync_to.nonce
466        || sequence > page.sync_to.sequence
467    {
468        return Err(NoteTransportError::Network(String::from(
469            "fetch response does not match the sync bound",
470        )));
471    }
472    if request_cursor.is_some_and(|request| {
473        nonce == request.nonce
474            && (sequence < request.sequence
475                || (!page.notes.is_empty() && sequence == request.sequence))
476    }) || (!page.notes.is_empty() && sequence == 0)
477        || (page.has_more && page.notes.is_empty())
478    {
479        return Err(NoteTransportError::Network(String::from(
480            "fetch response has invalid pagination progress",
481        )));
482    }
483    let mut notes = Vec::with_capacity(page.notes.len());
484    for info in &page.notes {
485        let note = rejoin_note(&info.header, &info.details_bytes)?;
486        if !tags.contains(&note.metadata().tag()) {
487            return Err(NoteTransportError::UnrequestedTag(note.metadata().tag()));
488        }
489        // The header ID includes attachments that the transport does not send.
490        notes.push((info.header.id(), note, info.block_hint));
491    }
492    Ok(notes)
493}
494
495/// Note transport cursor
496///
497/// Identifies a position in the note transport service's stored-note sequence.
498///
499/// The sequence is global across tags in one service database. Compare sequences only when their
500/// nonces match.
501#[derive(Clone, Copy, Debug, PartialEq, Eq)]
502pub struct NoteTransportCursor {
503    /// Identifies the service database.
504    pub nonce: u64,
505    /// Position in the stored-note sequence of the service database.
506    pub sequence: u64,
507}
508
509/// The part of a note that the note transport network sends to a recipient.
510///
511/// The transport sends the original header and details. It does not send note attachments. The
512/// constructor verifies that the header commits to the details.
513#[derive(Clone, Debug, PartialEq, Eq)]
514pub struct TransportNote {
515    header: NoteHeader,
516    details: NoteDetails,
517}
518
519impl TransportNote {
520    /// Creates a transport note from matching note parts.
521    pub fn new(header: NoteHeader, details: NoteDetails) -> Result<Self, NoteTransportError> {
522        validate_note_parts(&header, &details)?;
523        Ok(Self { header, details })
524    }
525
526    /// Returns the note header.
527    pub fn header(&self) -> &NoteHeader {
528        &self.header
529    }
530
531    /// Returns the note details.
532    pub fn details(&self) -> &NoteDetails {
533        &self.details
534    }
535
536    /// Returns the note header and details.
537    pub fn into_parts(self) -> (NoteHeader, NoteDetails) {
538        (self.header, self.details)
539    }
540}
541
542impl From<Note> for TransportNote {
543    fn from(note: Note) -> Self {
544        let header = *note.header();
545        let details = NoteDetails::from(note);
546        Self { header, details }
547    }
548}
549
550/// The main transport client trait for sending and receiving private notes.
551#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
552#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
553pub trait NoteTransportClient: Send + Sync {
554    /// Sends a note together with its inclusion proof.
555    ///
556    /// The transport carries the proof to the network. The network verifies `inclusion_proof`
557    /// before it stores the note and relays the exact commitment block to the recipient.
558    async fn send_note_with_proof(
559        &self,
560        note: TransportNote,
561        inclusion_proof: NoteInclusionProof,
562    ) -> Result<(), NoteTransportError>;
563
564    /// Fetches a page of notes after `cursor` and up to `sync_to` for the given tags.
565    ///
566    /// Without `cursor`, the page starts from the first retained note. Without `sync_to`, the
567    /// service uses its current head as the bound. The page returns the bound that the service
568    /// used. A cursor from another service database restarts from the first retained note.
569    async fn fetch_notes_page(
570        &self,
571        tags: &[NoteTag],
572        cursor: Option<NoteTransportCursor>,
573        sync_to: Option<NoteTransportCursor>,
574    ) -> Result<NoteTransportPage, NoteTransportError>;
575}
576
577/// A page of notes from the transport service.
578pub struct NoteTransportPage {
579    /// Notes in global sequence order.
580    pub notes: Vec<NoteInfo>,
581    /// Position of the last returned note. An empty page keeps the request position.
582    pub cursor: NoteTransportCursor,
583    /// Indicates that the requested tags have another page up to `sync_to`.
584    pub has_more: bool,
585    /// Inclusive upper bound of the request. When `has_more` is `false`, the page completes the
586    /// requested tags up to this bound.
587    pub sync_to: NoteTransportCursor,
588}
589
590/// Information about a note fetched from the note transport network
591#[derive(Debug, Clone)]
592pub struct NoteInfo {
593    /// Note header.
594    pub header: NoteHeader,
595    /// Serialized note details.
596    pub details_bytes: Vec<u8>,
597    /// Block from which the recipient starts scanning for the note's on-chain commitment. This is
598    /// either an unverified sender hint or the exact block verified by a proof-aware transport.
599    /// `None` applies the recipient's default lookback window.
600    pub block_hint: Option<BlockNumber>,
601}
602
603impl NoteInfo {
604    /// Builds a [`NoteInfo`] without a block hint (`block_hint` is `None`).
605    ///
606    /// Use the [`NoteInfo::block_hint`] field directly to attach a hint.
607    pub fn new(header: NoteHeader, details_bytes: Vec<u8>) -> Self {
608        Self { header, details_bytes, block_hint: None }
609    }
610}
611
612// SERIALIZATION
613// ================================================================================================
614
615impl Serializable for TransportNote {
616    fn write_into<W: ByteWriter>(&self, target: &mut W) {
617        self.header.write_into(target);
618        self.details.to_bytes().write_into(target);
619    }
620}
621
622impl Deserializable for TransportNote {
623    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
624        let header = NoteHeader::read_from(source)?;
625        let details_bytes = Vec::<u8>::read_from(source)?;
626        let details = NoteDetails::read_from_bytes(&details_bytes)?;
627        Self::new(header, details)
628            .map_err(|error| DeserializationError::InvalidValue(format!("{error}")))
629    }
630}
631
632impl Serializable for NoteInfo {
633    fn write_into<W: ByteWriter>(&self, target: &mut W) {
634        self.header.write_into(target);
635        self.details_bytes.write_into(target);
636        self.block_hint.write_into(target);
637    }
638}
639
640impl Deserializable for NoteInfo {
641    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
642        let header = NoteHeader::read_from(source)?;
643        let details_bytes = Vec::<u8>::read_from(source)?;
644        let block_hint = Option::<BlockNumber>::read_from(source)?;
645        Ok(NoteInfo { header, details_bytes, block_hint })
646    }
647}
648
649impl Serializable for NoteTransportCursor {
650    fn write_into<W: ByteWriter>(&self, target: &mut W) {
651        (self.nonce, self.sequence).write_into(target);
652    }
653}
654
655impl Deserializable for NoteTransportCursor {
656    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
657        let (nonce, sequence) = <(u64, u64)>::read_from(source)?;
658        Ok(Self { nonce, sequence })
659    }
660}
661
662fn rejoin_note(header: &NoteHeader, details_bytes: &[u8]) -> Result<Note, NoteTransportError> {
663    let mut reader = SliceReader::new(details_bytes);
664    let details = NoteDetails::read_from(&mut reader)?;
665    validate_note_parts(header, &details)?;
666    // The transport wire format only carries `NoteHeader` + serialized `NoteDetails`, not the
667    // attachments collection. We rejoin with empty attachments; this matches the original note only
668    // when it had no attachments in the first place.
669    let partial_metadata = *header.metadata().partial_metadata();
670    Ok(Note::new(
671        details.assets().clone(),
672        partial_metadata,
673        details.recipient().clone(),
674    ))
675}
676
677/// Checks that the note header commits to the supplied details.
678pub(crate) fn validate_note_parts(
679    header: &NoteHeader,
680    details: &NoteDetails,
681) -> Result<(), NoteTransportError> {
682    let header_commitment = header.details_commitment();
683    let details_commitment = details.commitment();
684    if header_commitment != details_commitment {
685        return Err(NoteTransportError::NoteDetailsMismatch {
686            header: header_commitment,
687            details: details_commitment,
688        });
689    }
690
691    Ok(())
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    fn cursor(sequence: u64) -> NoteTransportCursor {
699        NoteTransportCursor { nonce: 1, sequence }
700    }
701
702    fn empty_page(cursor: NoteTransportCursor, has_more: bool) -> NoteTransportPage {
703        NoteTransportPage {
704            notes: Vec::new(),
705            cursor,
706            has_more,
707            sync_to: self::cursor(20),
708        }
709    }
710
711    #[test]
712    fn transport_rejects_invalid_pagination() {
713        for page in [empty_page(cursor(9), false), empty_page(cursor(10), true)] {
714            assert!(validate_transport_page(&page, &[], Some(cursor(10)), None).is_err());
715        }
716    }
717
718    #[test]
719    fn transport_rejects_page_outside_sync_bound() {
720        let other_database = NoteTransportCursor { nonce: 2, sequence: 5 };
721        for (page, request_sync_to) in [
722            // The page changes the requested bound.
723            (empty_page(cursor(10), false), Some(cursor(21))),
724            // The page cursor passes the bound.
725            (empty_page(cursor(21), false), None),
726            // The page cursor and the bound come from different service databases.
727            (empty_page(other_database, false), None),
728        ] {
729            assert!(
730                validate_transport_page(&page, &[], Some(cursor(10)), request_sync_to).is_err()
731            );
732        }
733    }
734
735    #[test]
736    fn transport_accepts_pages_within_sync_bound() {
737        let page = empty_page(cursor(10), false);
738        assert!(validate_transport_page(&page, &[], Some(cursor(10)), Some(cursor(20))).is_ok());
739
740        // A cursor from another service database restarts from the first retained note.
741        let other_database = NoteTransportCursor { nonce: 2, sequence: 50 };
742        let page = empty_page(cursor(3), false);
743        assert!(validate_transport_page(&page, &[], Some(other_database), None).is_ok());
744    }
745
746    #[test]
747    fn transport_reads_legacy_tag_cursors() {
748        let tag = NoteTag::new(1);
749        let legacy = BTreeMap::from([(tag, Some((1u64, 4u64)))]).to_bytes();
750
751        let cursors =
752            BTreeMap::<NoteTag, Option<NoteTransportCursor>>::read_from_bytes(&legacy).unwrap();
753
754        assert_eq!(cursors, BTreeMap::from([(tag, Some(cursor(4)))]));
755    }
756
757    #[test]
758    fn transport_migrates_tag_cursors_to_lowest_cursor() {
759        let [first, second, third] = [1, 2, 3].map(NoteTag::new);
760        let cursors =
761            BTreeMap::from([(first, Some(cursor(7))), (second, Some(cursor(4))), (third, None)]);
762
763        let state = migrate_tag_cursors(cursors);
764
765        assert_eq!(state, Some((cursor(4), BTreeSet::from([first, second]))));
766    }
767
768    #[test]
769    fn transport_migration_drops_cursors_from_different_databases() {
770        let other_database = NoteTransportCursor { nonce: 2, sequence: 4 };
771        let cursors = BTreeMap::from([
772            (NoteTag::new(1), Some(cursor(7))),
773            (NoteTag::new(2), Some(other_database)),
774        ]);
775
776        assert_eq!(migrate_tag_cursors(cursors), None);
777        assert_eq!(migrate_tag_cursors(BTreeMap::new()), None);
778    }
779}