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