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;
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::{Client, ClientError};
29
30pub const NOTE_TRANSPORT_TESTNET_ENDPOINT: &str = "https://transport.miden.io";
31pub const NOTE_TRANSPORT_DEVNET_ENDPOINT: &str = "https://transport.devnet.miden.io";
32pub const NOTE_TRANSPORT_CURSOR_STORE_SETTING: &str = "note_transport_cursor";
33
34/// Settings key for the durable relay outbox: a serialized `Vec<NoteInfo>` of
35/// private notes whose transport delivery has not yet succeeded.
36/// `send_private_note` appends (replacing any entry with the same note id)
37/// before relaying; [`Client::flush_relay_outbox`] drains entries that re-send
38/// successfully. Reusing the settings k/v avoids a Store-trait schema change
39/// while surviving process restarts.
40pub const NOTE_TRANSPORT_OUTBOX_KEY: &str = "note_transport_outbox";
41
42/// Client note transport methods.
43impl<AUTH> Client<AUTH> {
44    /// Check if note transport connection is configured
45    pub fn is_note_transport_enabled(&self) -> bool {
46        self.note_transport_api.is_some()
47    }
48
49    /// Returns the Note Transport client
50    ///
51    /// Errors if the note transport is not configured.
52    pub(crate) fn get_note_transport_api(
53        &self,
54    ) -> Result<Arc<dyn NoteTransportClient>, NoteTransportError> {
55        self.note_transport_api.clone().ok_or(NoteTransportError::Disabled)
56    }
57
58    /// Send a note through the note transport network.
59    ///
60    /// The note will be end-to-end encrypted (unimplemented, currently plaintext)
61    /// using the provided recipient's `address` details.
62    /// The recipient will be able to retrieve this note through the note's [`NoteTag`].
63    ///
64    /// **Durability.** The relay payload is persisted to the outbox before the
65    /// transport call. If the call fails or is interrupted, the entry stays in
66    /// the outbox and is retried on the next [`Client::flush_relay_outbox`]
67    /// (which [`Client::sync_note_transport`] runs), so a transient transport
68    /// failure does not drop the note. The receiver dedupes by note id, so a
69    /// re-send after a partial success is harmless.
70    ///
71    /// Prefer [`Client::send_private_note_with_block_hint`], which also relays a block hint so the
72    /// recipient gets deterministic delivery instead of relying on its lookback heuristic.
73    #[deprecated(
74        since = "0.15.2",
75        note = "use `Client::send_private_note_with_block_hint` to relay a block hint for deterministic delivery"
76    )]
77    pub async fn send_private_note(
78        &mut self,
79        note: Note,
80        address: &Address,
81    ) -> Result<(), ClientError> {
82        self.relay_private_note(note, address, None).await
83    }
84
85    /// Send a note through the note transport network, relaying a block hint to the recipient.
86    ///
87    /// `block_hint` is the block from which the recipient should start scanning for the note's
88    /// on-chain commitment, instead of relying on its lookback heuristic. Any block at or before
89    /// the commitment is correct, and the chain tip at send time is a safe choice. A tighter value
90    /// just means less for the recipient to scan.
91    ///
92    /// The same durability guarantees as [`Client::send_private_note`] apply: the hint is
93    /// persisted with the relay payload, so a retried send preserves it.
94    pub async fn send_private_note_with_block_hint(
95        &mut self,
96        note: Note,
97        address: &Address,
98        block_hint: BlockNumber,
99    ) -> Result<(), ClientError> {
100        self.relay_private_note(note, address, Some(block_hint)).await
101    }
102
103    /// Shared relay path for [`Client::send_private_note`] and
104    /// [`Client::send_private_note_with_block_hint`]. `block_hint` is the optional block from which
105    /// the recipient should start scanning for the note's commitment.
106    async fn relay_private_note(
107        &self,
108        note: Note,
109        _address: &Address,
110        block_hint: Option<BlockNumber>,
111    ) -> Result<(), ClientError> {
112        let api = self.get_note_transport_api()?;
113
114        let header = *note.header();
115        let note_id = header.id();
116        let details = NoteDetails::from(note);
117        let details_bytes = details.to_bytes();
118        // e2ee impl hint:
119        // address.key().encrypt(details_bytes)
120
121        // Persist the payload before the network call so a failed or
122        // interrupted `send_note` leaves a recoverable record rather than
123        // losing the only copy with the call frame. The hint travels with the
124        // entry so a retried send relays the same value.
125        let entry = NoteInfo {
126            header,
127            details_bytes: details_bytes.clone(),
128            block_hint,
129        };
130        let mut outbox = self.load_relay_outbox().await?;
131        // Replace any existing entry for this note id so the latest payload
132        // wins when a still-pending note is re-sent.
133        outbox.retain(|e| e.header.id() != note_id);
134        outbox.push(entry);
135        self.save_relay_outbox(outbox).await?;
136
137        // Dispatch to the hint-carrying API only when a hint is present, otherwise use the plain
138        // `send_note`. The transport exposes a separate method per scenario.
139        match block_hint {
140            Some(block_hint) => {
141                api.send_note_with_block_hint(header, details_bytes, block_hint).await?;
142            },
143            None => {
144                api.send_note(header, details_bytes).await?;
145            },
146        }
147
148        // Relay succeeded — drop the entry. A failed store write here is
149        // tolerable: the next flush re-sends and the receiver dedupes by note
150        // id, so a stale entry never causes loss.
151        let mut outbox = self.load_relay_outbox().await?;
152        outbox.retain(|e| e.header.id() != note_id);
153        self.save_relay_outbox(outbox).await?;
154
155        Ok(())
156    }
157
158    /// Re-attempt every relay payload in the durable outbox. Each entry is a
159    /// private note whose previous transport delivery failed. Successful
160    /// re-sends are dropped; failures are kept for the next call. Every entry
161    /// is attempted independently, so one persistently-failing note does not
162    /// block the others.
163    ///
164    /// [`Client::sync_note_transport`] runs this automatically and ignores its
165    /// error, so a relay failure can't block a sync. Callers driving retries
166    /// themselves can invoke it directly and inspect the returned error.
167    pub async fn flush_relay_outbox(&self) -> Result<(), ClientError> {
168        let api = self.get_note_transport_api()?;
169
170        let entries = self.load_relay_outbox().await?;
171        if entries.is_empty() {
172            return Ok(());
173        }
174
175        // Attempt every entry independently so a single persistently-failing
176        // note can't block the rest. The outbox holds only the caller's own
177        // failed sends, so it stays small and this is not a meaningful burst.
178        let mut remaining = Vec::new();
179        let mut last_err: Option<NoteTransportError> = None;
180
181        for entry in entries {
182            let relayed = match entry.block_hint {
183                Some(block_hint) => {
184                    api.send_note_with_block_hint(
185                        entry.header,
186                        entry.details_bytes.clone(),
187                        block_hint,
188                    )
189                    .await
190                },
191                None => api.send_note(entry.header, entry.details_bytes.clone()).await,
192            };
193            match relayed {
194                Ok(()) => {},
195                Err(err) => {
196                    tracing::warn!(?err, "relay-outbox entry retry failed; will retry next sync");
197                    remaining.push(entry);
198                    last_err = Some(err);
199                },
200            }
201        }
202
203        self.save_relay_outbox(remaining).await?;
204
205        if let Some(err) = last_err {
206            return Err(err.into());
207        }
208        Ok(())
209    }
210
211    /// Load the durable relay outbox.
212    ///
213    /// Returns an empty `Vec` if the outbox key is absent. On deserialization
214    /// failure (schema mismatch or storage corruption) the entry is dropped and
215    /// an empty `Vec` is returned — leaving unreadable bytes in place would
216    /// block every subsequent relay because each sync would re-read them.
217    async fn load_relay_outbox(&self) -> Result<Vec<NoteInfo>, ClientError> {
218        let bytes = self
219            .store
220            .get_setting(String::from(NOTE_TRANSPORT_OUTBOX_KEY))
221            .await
222            .map_err(ClientError::StoreError)?;
223        let Some(bytes) = bytes else {
224            return Ok(Vec::new());
225        };
226        match Vec::<NoteInfo>::read_from_bytes(&bytes) {
227            Ok(entries) => Ok(entries),
228            Err(err) => {
229                tracing::warn!(?err, "dropping unreadable relay outbox; resetting to empty");
230                self.store
231                    .remove_setting(String::from(NOTE_TRANSPORT_OUTBOX_KEY))
232                    .await
233                    .map_err(ClientError::StoreError)?;
234                Ok(Vec::new())
235            },
236        }
237    }
238
239    /// Persist the relay outbox, removing the key entirely when empty so the
240    /// settings table doesn't accumulate empty-vec blobs.
241    async fn save_relay_outbox(&self, entries: Vec<NoteInfo>) -> Result<(), ClientError> {
242        let key = String::from(NOTE_TRANSPORT_OUTBOX_KEY);
243        if entries.is_empty() {
244            return self.store.remove_setting(key).await.map_err(ClientError::StoreError);
245        }
246        let bytes = entries.to_bytes();
247        self.store.set_setting(key, bytes).await.map_err(ClientError::StoreError)
248    }
249}
250
251impl<AUTH> Client<AUTH>
252where
253    AUTH: TransactionAuthenticator + Sync + 'static,
254{
255    /// Fetch notes for tracked note tags.
256    ///
257    /// The client will query the configured note transport node for all tracked note tags.
258    /// To list tracked tags please use [`Client::get_note_tags`]. To add a new note tag please use
259    /// [`Client::add_note_tag`].
260    /// Only notes directed at your addresses will be stored and readable given the use of
261    /// end-to-end encryption (unimplemented).
262    /// Fetched notes will be stored into the client's store.
263    ///
264    /// An internal pagination mechanism is employed to reduce the number of downloaded notes.
265    /// To fetch the full history of private notes for the tracked tags, use
266    /// [`Client::fetch_all_private_notes`].
267    pub async fn fetch_private_notes(&mut self) -> Result<(), ClientError> {
268        let note_tags: Vec<NoteTag> =
269            self.store.get_unique_note_tags().await?.into_iter().collect();
270        let cursor = self.store.get_note_transport_cursor().await?;
271
272        let (_, new_cursor) = self.fetch_transport_notes(cursor, &note_tags).await?;
273        self.store.update_note_transport_cursor(new_cursor).await?;
274
275        Ok(())
276    }
277
278    /// Fetches all notes for tracked note tags, draining the server's paginated
279    /// response by looping until the cursor stops advancing.
280    ///
281    /// Similar to [`Client::fetch_private_notes`] but ignores the stored
282    /// pagination cursor and re-scans from the beginning. The server-side
283    /// transport caps each response at a fixed batch size; this method issues
284    /// repeated fetch calls until one returns the same cursor it was given
285    /// (i.e. no new notes), so the documented "fetches all notes" semantics
286    /// hold regardless of how large the backlog is. Prefer
287    /// [`Client::fetch_private_notes`] for steady-state syncing to avoid
288    /// re-downloading already-seen notes.
289    pub async fn fetch_all_private_notes(&mut self) -> Result<(), ClientError> {
290        // Safety cap on a misbehaving server. At 500 notes per batch, 1000
291        // iterations covers 500k notes — well beyond any plausible retention
292        // window — and bounds the worst-case wall-clock at ~50s at 50ms/req.
293        // Hitting this signals a server bug, not an honest backlog.
294        const MAX_ITERATIONS: usize = 1_000;
295
296        let note_tags: Vec<NoteTag> =
297            self.store.get_unique_note_tags().await?.into_iter().collect();
298        // Snapshot the stored cursor up front so we can advance (never regress)
299        // it after the drain. Without this guard, starting the drain at
300        // `init()` and persisting per-batch would clobber a previously
301        // advanced cursor with the small `rcursor` of the first batch.
302        let stored_cursor = self.store.get_note_transport_cursor().await?;
303
304        let mut cursor = NoteTransportCursor::init();
305        for _ in 0..MAX_ITERATIONS {
306            let (_, new_cursor) = self.fetch_transport_notes(cursor, &note_tags).await?;
307            // Terminate on any lack of forward progress. A well-behaved server
308            // returns `new_cursor == cursor` when there are no new notes (since
309            // `rcursor = max(cursor, max_seq_returned)`); using `<=` here also
310            // handles implementations that return an `init()` cursor on empty
311            // batches (see the in-tree mock transport).
312            if new_cursor <= cursor {
313                let final_cursor = core::cmp::max(cursor, stored_cursor);
314                self.store.update_note_transport_cursor(final_cursor).await?;
315                return Ok(());
316            }
317            cursor = new_cursor;
318        }
319
320        Err(ClientError::NoteTransportError(NoteTransportError::PaginationDidNotTerminate(
321            MAX_ITERATIONS,
322        )))
323    }
324
325    /// Fetch one batch of notes from the note transport network for the provided tags.
326    ///
327    /// The server paginates; this method issues one RPC and returns the imported details
328    /// commitments together with the new cursor. The returned cursor equals the input cursor when
329    /// the batch was empty (i.e. no new notes). Callers that want to drain the full backlog should
330    /// loop until `new_cursor == cursor` (see [`Client::fetch_all_private_notes`]). Callers that do
331    /// steady-state polling (see [`Client::sync_state`] / [`Client::fetch_private_notes`]) should
332    /// call this once per tick with the stored cursor.
333    ///
334    /// Downloaded notes are imported into the local store. Persistence of the returned cursor is
335    /// left to the caller so that drain loops can guard against regression of an already-advanced
336    /// stored cursor.
337    pub(crate) async fn fetch_transport_notes(
338        &mut self,
339        cursor: NoteTransportCursor,
340        tags: &[NoteTag],
341    ) -> Result<(Vec<NoteId>, NoteTransportCursor), ClientError> {
342        // Fallback lookback window, in blocks, used only for notes the transport delivered
343        // without a sender-provided block hint. Scanning back from sync height handles
344        // the race where a note is committed on-chain just before the NTL delivers its data.
345        // Without it, check_expected_notes would scan from sync_height forward and miss the
346        // already-committed note. A sender-provided hint is deterministic and always preferred.
347        const NOTE_LOOKBACK_BLOCKS: u32 = 20;
348
349        let mut notes = Vec::new();
350        let (note_infos, rcursor) =
351            self.get_note_transport_api()?.fetch_notes(tags, cursor).await?;
352        for note_info in &note_infos {
353            // e2ee impl hint:
354            // for key in self.store.decryption_keys() try
355            // key.decrypt(details_bytes_encrypted)
356            let note = rejoin_note(&note_info.header, &note_info.details_bytes)?;
357            notes.push((note, note_info.block_hint));
358        }
359
360        let sync_height = self.get_sync_height().await?;
361        let fallback_after_block_num =
362            BlockNumber::from(sync_height.as_u32().saturating_sub(NOTE_LOOKBACK_BLOCKS));
363
364        let id_by_commitment: BTreeMap<NoteDetailsCommitment, NoteId> =
365            notes.iter().map(|(note, _)| (note.details_commitment(), note.id())).collect();
366
367        let mut note_requests = Vec::with_capacity(notes.len());
368        for (note, block_hint) in notes {
369            let tag = note.metadata().tag();
370            // Prefer the sender-provided hint, falling back to the lookback window when absent.
371            let after_block_num = block_hint.unwrap_or(fallback_after_block_num);
372            let note_file = NoteFile::ExpectedNote {
373                details: note.into(),
374                sync_hint: NoteSyncHint::new(after_block_num, tag),
375            };
376            note_requests.push(note_file);
377        }
378        let imported_commitments = self.import_notes(&note_requests).await?;
379        let imported_ids = imported_commitments
380            .into_iter()
381            .filter_map(|commitment| id_by_commitment.get(&commitment).copied())
382            .collect();
383
384        Ok((imported_ids, rcursor))
385    }
386}
387
388/// Note transport cursor
389///
390/// Pagination integer used to reduce the number of fetched notes from the note transport network,
391/// avoiding duplicate downloads.
392#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Eq, Ord)]
393pub struct NoteTransportCursor(u64);
394
395/// Note Transport update
396pub struct NoteTransportUpdate {
397    /// Pagination cursor for next fetch
398    pub cursor: NoteTransportCursor,
399    /// Fetched notes
400    pub notes: Vec<Note>,
401}
402
403impl NoteTransportCursor {
404    pub fn new(value: u64) -> Self {
405        Self(value)
406    }
407
408    pub fn init() -> Self {
409        Self::new(0)
410    }
411
412    pub fn value(&self) -> u64 {
413        self.0
414    }
415}
416
417impl From<u64> for NoteTransportCursor {
418    fn from(value: u64) -> Self {
419        Self::new(value)
420    }
421}
422
423/// The main transport client trait for sending and receiving encrypted notes
424#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
425#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
426pub trait NoteTransportClient: Send + Sync {
427    /// Send a note with optionally encrypted details
428    async fn send_note(
429        &self,
430        header: NoteHeader,
431        details: Vec<u8>,
432    ) -> Result<(), NoteTransportError>;
433
434    /// Send a note, relaying a block hint for the recipient's commitment scan.
435    ///
436    /// `block_hint` is the block from which the recipient should start scanning for the
437    /// note's commitment. The default implementation ignores it and delegates to
438    /// [`NoteTransportClient::send_note`], so existing implementors keep compiling. Transports
439    /// that can carry the hint (e.g. the gRPC client) override this.
440    async fn send_note_with_block_hint(
441        &self,
442        header: NoteHeader,
443        details: Vec<u8>,
444        _block_hint: BlockNumber,
445    ) -> Result<(), NoteTransportError> {
446        self.send_note(header, details).await
447    }
448
449    /// Fetch notes for given tags
450    ///
451    /// Downloads notes for given tags.
452    /// Returns notes labelled after the provided cursor (pagination), and an updated cursor.
453    async fn fetch_notes(
454        &self,
455        tag: &[NoteTag],
456        cursor: NoteTransportCursor,
457    ) -> Result<(Vec<NoteInfo>, NoteTransportCursor), NoteTransportError>;
458
459    /// Stream notes for a given tag
460    async fn stream_notes(
461        &self,
462        tag: NoteTag,
463        cursor: NoteTransportCursor,
464    ) -> Result<Box<dyn NoteStream>, NoteTransportError>;
465}
466
467/// Stream trait for note streaming
468pub trait NoteStream:
469    Stream<Item = Result<Vec<NoteInfo>, NoteTransportError>> + Send + Unpin
470{
471}
472
473/// Information about a note fetched from the note transport network
474#[derive(Debug, Clone)]
475pub struct NoteInfo {
476    /// Note header
477    pub header: NoteHeader,
478    /// Note details, can be encrypted
479    pub details_bytes: Vec<u8>,
480    /// Sender-provided block hint: the block from which the recipient should start scanning for
481    /// the note's on-chain commitment, instead of applying its default lookback window. `None`
482    /// when the sender did not provide a hint.
483    pub block_hint: Option<BlockNumber>,
484}
485
486impl NoteInfo {
487    /// Build a [`NoteInfo`] without a block hint (`block_hint` is `None`).
488    ///
489    /// Use the [`NoteInfo::block_hint`] field directly to attach a hint.
490    pub fn new(header: NoteHeader, details_bytes: Vec<u8>) -> Self {
491        Self { header, details_bytes, block_hint: None }
492    }
493}
494
495// SERIALIZATION
496// ================================================================================================
497
498impl Serializable for NoteInfo {
499    fn write_into<W: ByteWriter>(&self, target: &mut W) {
500        self.header.write_into(target);
501        self.details_bytes.write_into(target);
502        self.block_hint.write_into(target);
503    }
504}
505
506impl Deserializable for NoteInfo {
507    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
508        let header = NoteHeader::read_from(source)?;
509        let details_bytes = Vec::<u8>::read_from(source)?;
510        let block_hint = Option::<BlockNumber>::read_from(source)?;
511        Ok(NoteInfo { header, details_bytes, block_hint })
512    }
513}
514
515impl Serializable for NoteTransportCursor {
516    fn write_into<W: ByteWriter>(&self, target: &mut W) {
517        self.0.write_into(target);
518    }
519}
520
521impl Deserializable for NoteTransportCursor {
522    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
523        let value = u64::read_from(source)?;
524        Ok(Self::new(value))
525    }
526}
527
528fn rejoin_note(header: &NoteHeader, details_bytes: &[u8]) -> Result<Note, DeserializationError> {
529    let mut reader = SliceReader::new(details_bytes);
530    let details = NoteDetails::read_from(&mut reader)?;
531    // The transport wire format only carries `NoteHeader` + serialized `NoteDetails`, not the
532    // attachments collection. We rejoin with empty attachments; this matches the original note
533    // only when it had no attachments in the first place.
534    let partial_metadata = *header.metadata().partial_metadata();
535    Ok(Note::new(
536        details.assets().clone(),
537        partial_metadata,
538        details.recipient().clone(),
539    ))
540}