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 futures::Stream;
13use miden_protocol::address::Address;
14use miden_protocol::block::BlockNumber;
15use miden_protocol::note::{Note, NoteDetails, NoteDetailsCommitment, NoteHeader, NoteId, NoteTag};
16use miden_protocol::utils::serde::Serializable;
17use miden_standards::note::{NoteFile, NoteSyncHint};
18use miden_tx::auth::TransactionAuthenticator;
19use miden_tx::utils::serde::{
20    ByteReader,
21    ByteWriter,
22    Deserializable,
23    DeserializationError,
24    SliceReader,
25};
26
27pub use self::errors::NoteTransportError;
28use crate::sync::NoteTagSource;
29use crate::{Client, ClientError};
30
31pub const NOTE_TRANSPORT_TESTNET_ENDPOINT: &str = "https://transport.miden.io";
32pub const NOTE_TRANSPORT_DEVNET_ENDPOINT: &str = "https://transport.devnet.miden.io";
33pub const NOTE_TRANSPORT_CURSOR_STORE_SETTING: &str = "note_transport_cursor";
34
35/// Settings key for the note-transport backfill bookkeeping: a serialized `Vec<NoteTag>` of the
36/// `User`- and `Account`-source tags whose full history has already been fetched up to the global
37/// cursor. [`Client::sync_note_transport`] diffs the currently tracked tags against this set to
38/// find tags added after the cursor advanced, and backfills only those. Reusing the settings k/v
39/// avoids a Store-trait schema change while surviving process restarts.
40pub const NOTE_TRANSPORT_COVERED_TAGS_KEY: &str = "note_transport_covered_tags";
41
42/// Settings key for the durable relay outbox: a serialized `Vec<NoteInfo>` of
43/// private notes whose transport delivery has not yet succeeded.
44/// `send_private_note` appends (replacing any entry with the same note id)
45/// before relaying; [`Client::flush_relay_outbox`] drains entries that re-send
46/// successfully. Reusing the settings k/v avoids a Store-trait schema change
47/// while surviving process restarts.
48pub const NOTE_TRANSPORT_OUTBOX_KEY: &str = "note_transport_outbox";
49
50/// Client note transport methods.
51impl<AUTH> Client<AUTH> {
52    /// Check if note transport connection is configured
53    pub fn is_note_transport_enabled(&self) -> bool {
54        self.note_transport_api.is_some()
55    }
56
57    /// Returns the Note Transport client
58    ///
59    /// Errors if the note transport is not configured.
60    pub(crate) fn get_note_transport_api(
61        &self,
62    ) -> Result<Arc<dyn NoteTransportClient>, NoteTransportError> {
63        self.note_transport_api.clone().ok_or(NoteTransportError::Disabled)
64    }
65
66    /// Send a note through the note transport network.
67    ///
68    /// The note will be end-to-end encrypted (unimplemented, currently plaintext)
69    /// using the provided recipient's `address` details.
70    /// The recipient will be able to retrieve this note through the note's [`NoteTag`].
71    ///
72    /// **Durability.** The relay payload is persisted to the outbox before the
73    /// transport call. If the call fails or is interrupted, the entry stays in
74    /// the outbox and is retried on the next [`Client::flush_relay_outbox`]
75    /// (which [`Client::sync_note_transport`] runs), so a transient transport
76    /// failure does not drop the note. The receiver dedupes by note id, so a
77    /// re-send after a partial success is harmless.
78    ///
79    /// Prefer [`Client::send_private_note_with_block_hint`], which also relays a block hint so the
80    /// recipient gets deterministic delivery instead of relying on its lookback heuristic.
81    #[deprecated(
82        since = "0.15.2",
83        note = "use `Client::send_private_note_with_block_hint` to relay a block hint for deterministic delivery"
84    )]
85    pub async fn send_private_note(
86        &mut self,
87        note: Note,
88        address: &Address,
89    ) -> Result<(), ClientError> {
90        self.relay_private_note(note, address, None).await
91    }
92
93    /// Send a note through the note transport network, relaying a block hint to the recipient.
94    ///
95    /// `block_hint` is the block from which the recipient should start scanning for the note's
96    /// on-chain commitment, instead of relying on its lookback heuristic. Any block at or before
97    /// the commitment is correct, and the chain tip at send time is a safe choice. A tighter value
98    /// just means less for the recipient to scan.
99    ///
100    /// The same durability guarantees as [`Client::send_private_note`] apply: the hint is
101    /// persisted with the relay payload, so a retried send preserves it.
102    pub async fn send_private_note_with_block_hint(
103        &mut self,
104        note: Note,
105        address: &Address,
106        block_hint: BlockNumber,
107    ) -> Result<(), ClientError> {
108        self.relay_private_note(note, address, Some(block_hint)).await
109    }
110
111    /// Shared relay path for [`Client::send_private_note`] and
112    /// [`Client::send_private_note_with_block_hint`]. `block_hint` is the optional block from which
113    /// the recipient should start scanning for the note's commitment.
114    async fn relay_private_note(
115        &self,
116        note: Note,
117        _address: &Address,
118        block_hint: Option<BlockNumber>,
119    ) -> Result<(), ClientError> {
120        let api = self.get_note_transport_api()?;
121
122        let header = *note.header();
123        let note_id = header.id();
124        let details = NoteDetails::from(note);
125        let details_bytes = details.to_bytes();
126        // e2ee impl hint:
127        // address.key().encrypt(details_bytes)
128
129        // Persist the payload before the network call so a failed or
130        // interrupted `send_note` leaves a recoverable record rather than
131        // losing the only copy with the call frame. The hint travels with the
132        // entry so a retried send relays the same value.
133        let entry = NoteInfo {
134            header,
135            details_bytes: details_bytes.clone(),
136            block_hint,
137        };
138        let mut outbox = self.load_relay_outbox().await?;
139        // Replace any existing entry for this note id so the latest payload
140        // wins when a still-pending note is re-sent.
141        outbox.retain(|e| e.header.id() != note_id);
142        outbox.push(entry);
143        self.save_relay_outbox(outbox).await?;
144
145        // Dispatch to the hint-carrying API only when a hint is present, otherwise use the plain
146        // `send_note`. The transport exposes a separate method per scenario.
147        match block_hint {
148            Some(block_hint) => {
149                api.send_note_with_block_hint(header, details_bytes, block_hint).await?;
150            },
151            None => {
152                api.send_note(header, details_bytes).await?;
153            },
154        }
155
156        // Relay succeeded — drop the entry. A failed store write here is
157        // tolerable: the next flush re-sends and the receiver dedupes by note
158        // id, so a stale entry never causes loss.
159        let mut outbox = self.load_relay_outbox().await?;
160        outbox.retain(|e| e.header.id() != note_id);
161        self.save_relay_outbox(outbox).await?;
162
163        Ok(())
164    }
165
166    /// Re-attempt every relay payload in the durable outbox. Each entry is a
167    /// private note whose previous transport delivery failed. Successful
168    /// re-sends are dropped; failures are kept for the next call. Every entry
169    /// is attempted independently, so one persistently-failing note does not
170    /// block the others.
171    ///
172    /// [`Client::sync_note_transport`] runs this automatically and ignores its
173    /// error, so a relay failure can't block a sync. Callers driving retries
174    /// themselves can invoke it directly and inspect the returned error.
175    pub async fn flush_relay_outbox(&self) -> Result<(), ClientError> {
176        let api = self.get_note_transport_api()?;
177
178        let entries = self.load_relay_outbox().await?;
179        if entries.is_empty() {
180            return Ok(());
181        }
182
183        // Attempt every entry independently so a single persistently-failing
184        // note can't block the rest. The outbox holds only the caller's own
185        // failed sends, so it stays small and this is not a meaningful burst.
186        let mut remaining = Vec::new();
187        let mut last_err: Option<NoteTransportError> = None;
188
189        for entry in entries {
190            let relayed = match entry.block_hint {
191                Some(block_hint) => {
192                    api.send_note_with_block_hint(
193                        entry.header,
194                        entry.details_bytes.clone(),
195                        block_hint,
196                    )
197                    .await
198                },
199                None => api.send_note(entry.header, entry.details_bytes.clone()).await,
200            };
201            match relayed {
202                Ok(()) => {},
203                Err(err) => {
204                    tracing::warn!(?err, "relay-outbox entry retry failed; will retry next sync");
205                    remaining.push(entry);
206                    last_err = Some(err);
207                },
208            }
209        }
210
211        self.save_relay_outbox(remaining).await?;
212
213        if let Some(err) = last_err {
214            return Err(err.into());
215        }
216        Ok(())
217    }
218
219    /// Load the durable relay outbox.
220    ///
221    /// Returns an empty `Vec` if the outbox key is absent. On deserialization
222    /// failure (schema mismatch or storage corruption) the entry is dropped and
223    /// an empty `Vec` is returned — leaving unreadable bytes in place would
224    /// block every subsequent relay because each sync would re-read them.
225    async fn load_relay_outbox(&self) -> Result<Vec<NoteInfo>, ClientError> {
226        let bytes = self
227            .store
228            .get_setting(String::from(NOTE_TRANSPORT_OUTBOX_KEY))
229            .await
230            .map_err(ClientError::StoreError)?;
231        let Some(bytes) = bytes else {
232            return Ok(Vec::new());
233        };
234        match Vec::<NoteInfo>::read_from_bytes(&bytes) {
235            Ok(entries) => Ok(entries),
236            Err(err) => {
237                tracing::warn!(?err, "dropping unreadable relay outbox; resetting to empty");
238                self.store
239                    .remove_setting(String::from(NOTE_TRANSPORT_OUTBOX_KEY))
240                    .await
241                    .map_err(ClientError::StoreError)?;
242                Ok(Vec::new())
243            },
244        }
245    }
246
247    /// Persist the relay outbox, removing the key entirely when empty so the
248    /// settings table doesn't accumulate empty-vec blobs.
249    async fn save_relay_outbox(&self, entries: Vec<NoteInfo>) -> Result<(), ClientError> {
250        let key = String::from(NOTE_TRANSPORT_OUTBOX_KEY);
251        if entries.is_empty() {
252            self.store.remove_setting(key).await.map_err(ClientError::StoreError)?;
253            return Ok(());
254        }
255        let bytes = entries.to_bytes();
256        self.store.set_setting(key, bytes).await.map_err(ClientError::StoreError)
257    }
258
259    /// The set of tracked tags eligible for history backfill.
260    ///
261    /// Only `User`- and `Account`-source tags qualify: those are the tags a consumer explicitly
262    /// started tracking (via [`Client::add_note_tag`], account import, or address creation) and may
263    /// therefore have historical private notes sitting below the global cursor. `Note`-source tags
264    /// are created by transport delivery and note import, so backfilling them would re-fetch tags
265    /// the fetch path itself just registered; `Subscription` tags are excluded for the same reason.
266    async fn backfill_candidate_tags(&self) -> Result<BTreeSet<NoteTag>, ClientError> {
267        let tags = self
268            .store
269            .get_note_tags()
270            .await?
271            .into_iter()
272            .filter(|record| {
273                matches!(record.source, NoteTagSource::User | NoteTagSource::Account(_))
274            })
275            .map(|record| record.tag)
276            .collect();
277        Ok(tags)
278    }
279
280    /// Load the set of tags whose history has already been fetched up to the global cursor.
281    ///
282    /// Returns an empty set when the key is absent (e.g. a store that predates the feature). On a
283    /// deserialization failure the entry is dropped and an empty set is returned: re-treating every
284    /// tracked tag as new only triggers a one-off backfill, which dedupes, whereas leaving
285    /// unreadable bytes in place would fail every subsequent sync.
286    async fn load_covered_tags(&self) -> Result<BTreeSet<NoteTag>, ClientError> {
287        let bytes = self
288            .store
289            .get_setting(String::from(NOTE_TRANSPORT_COVERED_TAGS_KEY))
290            .await
291            .map_err(ClientError::StoreError)?;
292        let Some(bytes) = bytes else {
293            return Ok(BTreeSet::new());
294        };
295        match BTreeSet::<NoteTag>::read_from_bytes(&bytes) {
296            Ok(tags) => Ok(tags),
297            Err(err) => {
298                tracing::warn!(?err, "dropping unreadable covered-tags set; resetting to empty");
299                self.store
300                    .remove_setting(String::from(NOTE_TRANSPORT_COVERED_TAGS_KEY))
301                    .await
302                    .map_err(ClientError::StoreError)?;
303                Ok(BTreeSet::new())
304            },
305        }
306    }
307
308    /// Persist the covered-tags set, removing the key entirely when empty so the settings table
309    /// doesn't accumulate empty-vec blobs.
310    async fn save_covered_tags(&self, tags: &BTreeSet<NoteTag>) -> Result<(), ClientError> {
311        let key = String::from(NOTE_TRANSPORT_COVERED_TAGS_KEY);
312        if tags.is_empty() {
313            self.store.remove_setting(key).await.map_err(ClientError::StoreError)?;
314            return Ok(());
315        }
316        self.store
317            .set_setting(key, tags.to_bytes())
318            .await
319            .map_err(ClientError::StoreError)
320    }
321}
322
323impl<AUTH> Client<AUTH>
324where
325    AUTH: TransactionAuthenticator + Sync + 'static,
326{
327    /// Per-sync cap on the number of newly tracked tags to backfill. Bounds the burst when many
328    /// tags are registered at once (e.g. restoring many accounts or addresses). Deferred tags stay
329    /// uncovered and are picked up on subsequent syncs.
330    pub const MAX_BACKFILL_TAGS_PER_SYNC: usize = 64;
331
332    /// Safety cap on the per-tag backfill drain. A well-behaved server eventually returns no
333    /// forward cursor progress, ending the loop; this bound only guards against a server that
334    /// advances the cursor indefinitely without ever returning an empty batch. It is far above any
335    /// honest per-tag backlog, so reaching it signals a server bug rather than real history.
336    const MAX_BACKFILL_ITERATIONS: usize = 1_000;
337
338    /// Fetch notes for tracked note tags.
339    ///
340    /// The client will query the configured note transport node for all tracked note tags.
341    /// To list tracked tags please use [`Client::get_note_tags`]. To add a new note tag please use
342    /// [`Client::add_note_tag`].
343    /// Only notes directed at your addresses will be stored and readable given the use of
344    /// end-to-end encryption (unimplemented).
345    /// Fetched notes will be stored into the client's store.
346    ///
347    /// An internal pagination mechanism is employed to reduce the number of downloaded notes: this
348    /// fetches only notes past the stored cursor. Historical notes for a newly tracked tag are
349    /// recovered automatically by [`Client::sync_note_transport`], which backfills each new tag.
350    pub async fn fetch_private_notes(&mut self) -> Result<(), ClientError> {
351        let note_tags: Vec<NoteTag> =
352            self.store.get_unique_note_tags().await?.into_iter().collect();
353        let cursor = self.store.get_note_transport_cursor().await?;
354
355        let (_, new_cursor) = self.fetch_transport_notes(cursor, &note_tags).await?;
356        self.store.update_note_transport_cursor(new_cursor).await?;
357
358        Ok(())
359    }
360
361    /// Backfill historical private notes for tags added after the global cursor advanced.
362    ///
363    /// The global transport cursor is shared across all tracked tags and only moves forward, so a
364    /// tag that starts being tracked late never sees its notes that already sit below the cursor.
365    /// This diffs the tracked `User`/`Account` tags (see [`Self::backfill_candidate_tags`]) against
366    /// the persisted covered set (see [`NOTE_TRANSPORT_COVERED_TAGS_KEY`]) and drains each newly
367    /// tracked tag from the start, fetching only that tag's own history rather than re-scanning
368    /// everything. Tags no longer tracked are dropped from the covered set so a later re-add
369    /// backfills again instead of resuming from a stale mark. Imports dedupe, so the overlap with
370    /// the steady-state stream is harmless.
371    ///
372    /// At most [`Self::MAX_BACKFILL_TAGS_PER_SYNC`] tags are backfilled per call; any remainder
373    /// stays uncovered and is picked up on the next sync. Returns the ids of notes imported here.
374    pub(crate) async fn backfill_new_tags(&mut self) -> Result<Vec<NoteId>, ClientError> {
375        let candidates = self.backfill_candidate_tags().await?;
376        let loaded = self.load_covered_tags().await?;
377
378        // Drop tags no longer tracked. Keeping a removed tag marked covered would make a later
379        // re-add skip its backlog, silently missing notes that arrived while it was untracked.
380        let mut covered: BTreeSet<NoteTag> = loaded.intersection(&candidates).copied().collect();
381        if covered.len() != loaded.len() {
382            self.save_covered_tags(&covered).await?;
383        }
384
385        let new_tags: Vec<NoteTag> = candidates.difference(&covered).copied().collect();
386
387        let mut imported_ids = Vec::new();
388        for tag in new_tags.into_iter().take(Self::MAX_BACKFILL_TAGS_PER_SYNC) {
389            imported_ids.extend(self.backfill_tag(tag).await?);
390            covered.insert(tag);
391            // Persist after each tag so a crash mid-backfill keeps completed tags covered. A redo
392            // is harmless because imports dedupe; the dangerous direction (marking covered before
393            // the import lands) never happens.
394            self.save_covered_tags(&covered).await?;
395        }
396
397        Ok(imported_ids)
398    }
399
400    /// Drain a single tag's full history from the transport, paging until the cursor stops
401    /// advancing. Uses a local cursor and never touches the global one, so it cannot regress
402    /// steady-state progress. Returns the ids of the notes it imported.
403    async fn backfill_tag(&mut self, tag: NoteTag) -> Result<Vec<NoteId>, ClientError> {
404        let mut imported_ids = Vec::new();
405        let mut cursor = NoteTransportCursor::init();
406        for _ in 0..Self::MAX_BACKFILL_ITERATIONS {
407            let (ids, new_cursor) = self.fetch_transport_notes(cursor, &[tag]).await?;
408            imported_ids.extend(ids);
409            // Terminate on any lack of forward progress. A well-behaved server returns
410            // `new_cursor == cursor` when there are no new notes for this tag (since
411            // `rcursor = max(cursor, max_seq_returned)`); using `<=` also handles implementations
412            // that return an `init()` cursor on empty batches (see the in-tree mock transport).
413            if new_cursor <= cursor {
414                return Ok(imported_ids);
415            }
416            cursor = new_cursor;
417        }
418
419        Err(ClientError::NoteTransportError(NoteTransportError::PaginationDidNotTerminate(
420            Self::MAX_BACKFILL_ITERATIONS,
421        )))
422    }
423
424    /// Fetch one batch of notes from the note transport network for the provided tags.
425    ///
426    /// The server paginates; this method issues one RPC and returns the imported details
427    /// commitments together with the new cursor. The returned cursor equals the input cursor when
428    /// the batch was empty (i.e. no new notes). Callers that want to drain a tag's full backlog
429    /// should loop until `new_cursor == cursor` (see [`Client::backfill_new_tags`]). Callers that
430    /// do steady-state polling (see [`Client::sync_state`] / [`Client::fetch_private_notes`])
431    /// should call this once per tick with the stored cursor.
432    ///
433    /// Downloaded notes are imported into the local store. Persistence of the returned cursor is
434    /// left to the caller so that drain loops can guard against regression of an already-advanced
435    /// stored cursor.
436    pub(crate) async fn fetch_transport_notes(
437        &mut self,
438        cursor: NoteTransportCursor,
439        tags: &[NoteTag],
440    ) -> Result<(Vec<NoteId>, NoteTransportCursor), ClientError> {
441        // Fallback lookback window, in blocks, used only for notes the transport delivered
442        // without a sender-provided block hint. Scanning back from sync height handles
443        // the race where a note is committed on-chain just before the NTL delivers its data.
444        // Without it, check_expected_notes would scan from sync_height forward and miss the
445        // already-committed note. A sender-provided hint is deterministic and always preferred.
446        const NOTE_LOOKBACK_BLOCKS: u32 = 20;
447
448        let mut notes = Vec::new();
449        // TODO: perhaps we should not need to map received IDs with details commitments, and
450        // instead we may allow `InputNoteRecord` to optionally keep NoteIds. Then within
451        // `import_note` we could match everything by ID and remove this map check
452        let mut id_by_commitment: BTreeMap<NoteDetailsCommitment, NoteId> = BTreeMap::new();
453        let (note_infos, rcursor) =
454            self.get_note_transport_api()?.fetch_notes(tags, cursor).await?;
455        for note_info in &note_infos {
456            // e2ee impl hint:
457            // for key in self.store.decryption_keys() try
458            // key.decrypt(details_bytes_encrypted)
459            let note = rejoin_note(&note_info.header, &note_info.details_bytes)?;
460
461            // The header carries the attachment-aware (on-chain) note id; the rejoined note has
462            // empty attachments and would hash to a different id, so key off the header.
463            id_by_commitment.insert(note.details_commitment(), note_info.header.id());
464            notes.push((note, note_info.block_hint));
465        }
466
467        let sync_height = self.get_sync_height().await?;
468        let fallback_after_block_num =
469            BlockNumber::from(sync_height.as_u32().saturating_sub(NOTE_LOOKBACK_BLOCKS));
470
471        let mut note_requests = Vec::with_capacity(notes.len());
472        for (note, block_hint) in notes {
473            let tag = note.metadata().tag();
474            // Prefer the sender-provided hint, falling back to the lookback window when absent.
475            let after_block_num = block_hint.unwrap_or(fallback_after_block_num);
476            let note_file = NoteFile::ExpectedNote {
477                details: note.into(),
478                sync_hint: NoteSyncHint::new(after_block_num, tag),
479            };
480            note_requests.push(note_file);
481        }
482        let imported_commitments = self.import_notes(&note_requests).await?;
483        let imported_ids = imported_commitments
484            .into_iter()
485            .filter_map(|commitment| id_by_commitment.get(&commitment).copied())
486            .collect();
487
488        Ok((imported_ids, rcursor))
489    }
490}
491
492/// Note transport cursor
493///
494/// Pagination integer used to reduce the number of fetched notes from the note transport network,
495/// avoiding duplicate downloads.
496#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Eq, Ord)]
497pub struct NoteTransportCursor(u64);
498
499/// Note Transport update
500pub struct NoteTransportUpdate {
501    /// Pagination cursor for next fetch
502    pub cursor: NoteTransportCursor,
503    /// Fetched notes
504    pub notes: Vec<Note>,
505}
506
507impl NoteTransportCursor {
508    pub fn new(value: u64) -> Self {
509        Self(value)
510    }
511
512    pub fn init() -> Self {
513        Self::new(0)
514    }
515
516    pub fn value(&self) -> u64 {
517        self.0
518    }
519}
520
521impl From<u64> for NoteTransportCursor {
522    fn from(value: u64) -> Self {
523        Self::new(value)
524    }
525}
526
527/// The main transport client trait for sending and receiving encrypted notes
528#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
529#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
530pub trait NoteTransportClient: Send + Sync {
531    /// Send a note with optionally encrypted details
532    async fn send_note(
533        &self,
534        header: NoteHeader,
535        details: Vec<u8>,
536    ) -> Result<(), NoteTransportError>;
537
538    /// Send a note, relaying a block hint for the recipient's commitment scan.
539    ///
540    /// `block_hint` is the block from which the recipient should start scanning for the
541    /// note's commitment. The default implementation ignores it and delegates to
542    /// [`NoteTransportClient::send_note`], so existing implementors keep compiling. Transports
543    /// that can carry the hint (e.g. the gRPC client) override this.
544    async fn send_note_with_block_hint(
545        &self,
546        header: NoteHeader,
547        details: Vec<u8>,
548        _block_hint: BlockNumber,
549    ) -> Result<(), NoteTransportError> {
550        self.send_note(header, details).await
551    }
552
553    /// Fetch notes for given tags
554    ///
555    /// Downloads notes for given tags.
556    /// Returns notes labelled after the provided cursor (pagination), and an updated cursor.
557    async fn fetch_notes(
558        &self,
559        tag: &[NoteTag],
560        cursor: NoteTransportCursor,
561    ) -> Result<(Vec<NoteInfo>, NoteTransportCursor), NoteTransportError>;
562
563    /// Stream notes for a given tag
564    async fn stream_notes(
565        &self,
566        tag: NoteTag,
567        cursor: NoteTransportCursor,
568    ) -> Result<Box<dyn NoteStream>, NoteTransportError>;
569}
570
571/// Stream trait for note streaming
572pub trait NoteStream:
573    Stream<Item = Result<Vec<NoteInfo>, NoteTransportError>> + Send + Unpin
574{
575}
576
577/// Information about a note fetched from the note transport network
578#[derive(Debug, Clone)]
579pub struct NoteInfo {
580    /// Note header
581    pub header: NoteHeader,
582    /// Note details, can be encrypted
583    pub details_bytes: Vec<u8>,
584    /// Sender-provided block hint: the block from which the recipient should start scanning for
585    /// the note's on-chain commitment, instead of applying its default lookback window. `None`
586    /// when the sender did not provide a hint.
587    pub block_hint: Option<BlockNumber>,
588}
589
590impl NoteInfo {
591    /// Build a [`NoteInfo`] without a block hint (`block_hint` is `None`).
592    ///
593    /// Use the [`NoteInfo::block_hint`] field directly to attach a hint.
594    pub fn new(header: NoteHeader, details_bytes: Vec<u8>) -> Self {
595        Self { header, details_bytes, block_hint: None }
596    }
597}
598
599// SERIALIZATION
600// ================================================================================================
601
602impl Serializable for NoteInfo {
603    fn write_into<W: ByteWriter>(&self, target: &mut W) {
604        self.header.write_into(target);
605        self.details_bytes.write_into(target);
606        self.block_hint.write_into(target);
607    }
608}
609
610impl Deserializable for NoteInfo {
611    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
612        let header = NoteHeader::read_from(source)?;
613        let details_bytes = Vec::<u8>::read_from(source)?;
614        let block_hint = Option::<BlockNumber>::read_from(source)?;
615        Ok(NoteInfo { header, details_bytes, block_hint })
616    }
617}
618
619impl Serializable for NoteTransportCursor {
620    fn write_into<W: ByteWriter>(&self, target: &mut W) {
621        self.0.write_into(target);
622    }
623}
624
625impl Deserializable for NoteTransportCursor {
626    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
627        let value = u64::read_from(source)?;
628        Ok(Self::new(value))
629    }
630}
631
632fn rejoin_note(header: &NoteHeader, details_bytes: &[u8]) -> Result<Note, DeserializationError> {
633    let mut reader = SliceReader::new(details_bytes);
634    let details = NoteDetails::read_from(&mut reader)?;
635    // The transport wire format only carries `NoteHeader` + serialized `NoteDetails`, not the
636    // attachments collection. We rejoin with empty attachments; this matches the original note
637    // only when it had no attachments in the first place.
638    let partial_metadata = *header.metadata().partial_metadata();
639    Ok(Note::new(
640        details.assets().clone(),
641        partial_metadata,
642        details.recipient().clone(),
643    ))
644}