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_tx::auth::TransactionAuthenticator;
18use miden_tx::utils::serde::{
19    ByteReader,
20    ByteWriter,
21    Deserializable,
22    DeserializationError,
23    SliceReader,
24};
25
26pub use self::errors::NoteTransportError;
27use crate::note::{NoteFile, NoteSyncHint};
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 private notes whose
44/// transport delivery has not yet succeeded. `send_private_note` appends (replacing any entry with
45/// the same note id) before relaying; [`Client::flush_relay_outbox`] drains entries that re-send
46/// successfully. Reusing the settings k/v avoids a Store-trait schema change while surviving
47/// 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) using the
69    /// provided recipient's `address` details. The recipient will be able to retrieve this note
70    /// through the note's [`NoteTag`].
71    ///
72    /// **Durability.** The relay payload is persisted to the outbox before the transport call. If
73    /// the call fails or is interrupted, the entry stays in the outbox and is retried on the next
74    /// [`Client::flush_relay_outbox`] (which [`Client::sync_note_transport`] runs), so a transient
75    /// transport failure does not drop the note. The receiver dedupes by note id, so a re-send
76    /// after a partial success is harmless.
77    ///
78    /// Prefer [`Client::send_private_note_with_block_hint`], which also relays a block hint so the
79    /// recipient gets deterministic delivery instead of relying on its lookback heuristic.
80    #[deprecated(
81        since = "0.15.2",
82        note = "use `Client::send_private_note_with_block_hint` to relay a block hint for deterministic delivery"
83    )]
84    pub async fn send_private_note(
85        &mut self,
86        note: Note,
87        address: &Address,
88    ) -> Result<(), ClientError> {
89        self.relay_private_note(note, address, None).await
90    }
91
92    /// Send a note through the note transport network, relaying a block hint to the recipient.
93    ///
94    /// `block_hint` is the block from which the recipient should start scanning for the note's
95    /// on-chain commitment, instead of relying on its lookback heuristic. Any block at or before
96    /// the commitment is correct, and the chain tip at send time is a safe choice. A tighter value
97    /// just means less for the recipient to scan.
98    ///
99    /// The same durability guarantees as [`Client::send_private_note`] apply: the hint is persisted
100    /// with the relay payload, so a retried send preserves it.
101    pub async fn send_private_note_with_block_hint(
102        &mut self,
103        note: Note,
104        address: &Address,
105        block_hint: BlockNumber,
106    ) -> Result<(), ClientError> {
107        self.relay_private_note(note, address, Some(block_hint)).await
108    }
109
110    /// Shared relay path for [`Client::send_private_note`] and
111    /// [`Client::send_private_note_with_block_hint`]. `block_hint` is the optional block from which
112    /// the recipient should start scanning for the note's commitment.
113    async fn relay_private_note(
114        &self,
115        note: Note,
116        _address: &Address,
117        block_hint: Option<BlockNumber>,
118    ) -> Result<(), ClientError> {
119        let api = self.get_note_transport_api()?;
120
121        let header = *note.header();
122        let note_id = header.id();
123        let details = NoteDetails::from(note);
124        let details_bytes = details.to_bytes();
125        // e2ee impl hint: address.key().encrypt(details_bytes)
126
127        // Persist the payload before the network call so a failed or interrupted `send_note` leaves
128        // a recoverable record rather than losing the only copy with the call frame. The hint
129        // travels with the entry so a retried send relays the same value.
130        let entry = NoteInfo {
131            header,
132            details_bytes: details_bytes.clone(),
133            block_hint,
134        };
135        let mut outbox = self.load_relay_outbox().await?;
136        // Replace any existing entry for this note id so the latest payload wins when a
137        // still-pending note is re-sent.
138        outbox.retain(|e| e.header.id() != note_id);
139        outbox.push(entry);
140        self.save_relay_outbox(outbox).await?;
141
142        // Dispatch to the hint-carrying API only when a hint is present, otherwise use the plain
143        // `send_note`. The transport exposes a separate method per scenario.
144        match block_hint {
145            Some(block_hint) => {
146                api.send_note_with_block_hint(header, details_bytes, block_hint).await?;
147            },
148            None => {
149                api.send_note(header, details_bytes).await?;
150            },
151        }
152
153        // Relay succeeded — drop the entry. A failed store write here is tolerable: the next flush
154        // re-sends and the receiver dedupes by note id, so a stale entry never causes loss.
155        let mut outbox = self.load_relay_outbox().await?;
156        outbox.retain(|e| e.header.id() != note_id);
157        self.save_relay_outbox(outbox).await?;
158
159        Ok(())
160    }
161
162    /// Re-attempt every relay payload in the durable outbox. Each entry is a private note whose
163    /// previous transport delivery failed. Successful re-sends are dropped; failures are kept for
164    /// the next call. Every entry is attempted independently, so one persistently-failing note does
165    /// not block the others.
166    ///
167    /// [`Client::sync_note_transport`] runs this automatically and ignores its error, so a relay
168    /// failure can't block a sync. Callers driving retries themselves can invoke it directly and
169    /// inspect the returned error.
170    pub async fn flush_relay_outbox(&self) -> Result<(), ClientError> {
171        let api = self.get_note_transport_api()?;
172
173        let entries = self.load_relay_outbox().await?;
174        if entries.is_empty() {
175            return Ok(());
176        }
177
178        // Attempt every entry independently so a single persistently-failing note can't block the
179        // rest. The outbox holds only the caller's own failed sends, so it stays small and this is
180        // not a meaningful burst.
181        let mut remaining = Vec::new();
182        let mut last_err: Option<NoteTransportError> = None;
183
184        for entry in entries {
185            let relayed = match entry.block_hint {
186                Some(block_hint) => {
187                    api.send_note_with_block_hint(
188                        entry.header,
189                        entry.details_bytes.clone(),
190                        block_hint,
191                    )
192                    .await
193                },
194                None => api.send_note(entry.header, entry.details_bytes.clone()).await,
195            };
196            match relayed {
197                Ok(()) => {},
198                Err(err) => {
199                    tracing::warn!(?err, "relay-outbox entry retry failed; will retry next sync");
200                    remaining.push(entry);
201                    last_err = Some(err);
202                },
203            }
204        }
205
206        self.save_relay_outbox(remaining).await?;
207
208        if let Some(err) = last_err {
209            return Err(err.into());
210        }
211        Ok(())
212    }
213
214    /// Load the durable relay outbox.
215    ///
216    /// Returns an empty `Vec` if the outbox key is absent. On deserialization failure (schema
217    /// mismatch or storage corruption) the entry is dropped and an empty `Vec` is returned —
218    /// leaving unreadable bytes in place would block every subsequent relay because each sync would
219    /// re-read them.
220    async fn load_relay_outbox(&self) -> Result<Vec<NoteInfo>, ClientError> {
221        let bytes = self
222            .store
223            .get_setting(SettingScope::Client, String::from(NOTE_TRANSPORT_OUTBOX_KEY))
224            .await
225            .map_err(ClientError::StoreError)?;
226        let Some(bytes) = bytes else {
227            return Ok(Vec::new());
228        };
229        match Vec::<NoteInfo>::read_from_bytes(&bytes) {
230            Ok(entries) => Ok(entries),
231            Err(err) => {
232                tracing::warn!(?err, "dropping unreadable relay outbox; resetting to empty");
233                self.store
234                    .remove_setting(SettingScope::Client, String::from(NOTE_TRANSPORT_OUTBOX_KEY))
235                    .await
236                    .map_err(ClientError::StoreError)?;
237                Ok(Vec::new())
238            },
239        }
240    }
241
242    /// Persist the relay outbox, removing the key entirely when empty so the settings table doesn't
243    /// accumulate empty-vec blobs.
244    async fn save_relay_outbox(&self, entries: Vec<NoteInfo>) -> Result<(), ClientError> {
245        let key = String::from(NOTE_TRANSPORT_OUTBOX_KEY);
246        if entries.is_empty() {
247            self.store
248                .remove_setting(SettingScope::Client, key)
249                .await
250                .map_err(ClientError::StoreError)?;
251            return Ok(());
252        }
253        let bytes = entries.to_bytes();
254        self.store
255            .set_setting(SettingScope::Client, key, bytes)
256            .await
257            .map_err(ClientError::StoreError)
258    }
259
260    /// The set of tracked tags eligible for history backfill.
261    ///
262    /// Only `User`- and `Account`-source tags qualify: those are the tags a consumer explicitly
263    /// started tracking (via [`Client::add_note_tag`], account import, or address creation) and may
264    /// therefore have historical private notes sitting below the global cursor. `Note`-source tags
265    /// are created by transport delivery and note import, so backfilling them would re-fetch tags
266    /// the fetch path itself just registered; `Subscription` tags are excluded for the same reason.
267    async fn backfill_candidate_tags(&self) -> Result<BTreeSet<NoteTag>, ClientError> {
268        let tags = self
269            .store
270            .get_note_tags()
271            .await?
272            .into_iter()
273            .filter(|record| {
274                matches!(record.source, NoteTagSource::User | NoteTagSource::Account(_))
275            })
276            .map(|record| record.tag)
277            .collect();
278        Ok(tags)
279    }
280
281    /// Load the set of tags whose history has already been fetched up to the global cursor.
282    ///
283    /// Returns an empty set when the key is absent (e.g. a store that predates the feature). On a
284    /// deserialization failure the entry is dropped and an empty set is returned: re-treating every
285    /// tracked tag as new only triggers a one-off backfill, which dedupes, whereas leaving
286    /// unreadable bytes in place would fail every subsequent sync.
287    async fn load_covered_tags(&self) -> Result<BTreeSet<NoteTag>, ClientError> {
288        let bytes = self
289            .store
290            .get_setting(SettingScope::Client, String::from(NOTE_TRANSPORT_COVERED_TAGS_KEY))
291            .await
292            .map_err(ClientError::StoreError)?;
293        let Some(bytes) = bytes else {
294            return Ok(BTreeSet::new());
295        };
296        match BTreeSet::<NoteTag>::read_from_bytes(&bytes) {
297            Ok(tags) => Ok(tags),
298            Err(err) => {
299                tracing::warn!(?err, "dropping unreadable covered-tags set; resetting to empty");
300                self.store
301                    .remove_setting(
302                        SettingScope::Client,
303                        String::from(NOTE_TRANSPORT_COVERED_TAGS_KEY),
304                    )
305                    .await
306                    .map_err(ClientError::StoreError)?;
307                Ok(BTreeSet::new())
308            },
309        }
310    }
311
312    /// Persist the covered-tags set, removing the key entirely when empty so the settings table
313    /// doesn't accumulate empty-vec blobs.
314    async fn save_covered_tags(&self, tags: &BTreeSet<NoteTag>) -> Result<(), ClientError> {
315        let key = String::from(NOTE_TRANSPORT_COVERED_TAGS_KEY);
316        if tags.is_empty() {
317            self.store
318                .remove_setting(SettingScope::Client, key)
319                .await
320                .map_err(ClientError::StoreError)?;
321            return Ok(());
322        }
323        self.store
324            .set_setting(SettingScope::Client, key, tags.to_bytes())
325            .await
326            .map_err(ClientError::StoreError)
327    }
328}
329
330impl<AUTH> Client<AUTH>
331where
332    AUTH: TransactionAuthenticator + Sync + 'static,
333{
334    /// Per-sync cap on the number of newly tracked tags to backfill. Bounds the burst when many
335    /// tags are registered at once (e.g. restoring many accounts or addresses). Deferred tags stay
336    /// uncovered and are picked up on subsequent syncs.
337    pub const MAX_BACKFILL_TAGS_PER_SYNC: usize = 64;
338
339    /// Safety cap on the per-tag backfill drain. A well-behaved server eventually returns no
340    /// forward cursor progress, ending the loop; this bound only guards against a server that
341    /// advances the cursor indefinitely without ever returning an empty batch. It is far above any
342    /// honest per-tag backlog, so reaching it signals a server bug rather than real history.
343    const MAX_BACKFILL_ITERATIONS: usize = 1_000;
344
345    /// Fetch notes for tracked note tags.
346    ///
347    /// The client will query the configured note transport node for all tracked note tags. To list
348    /// tracked tags please use [`Client::get_note_tags`]. To add a new note tag please use
349    /// [`Client::add_note_tag`]. Only notes directed at your addresses will be stored and readable
350    /// given the use of end-to-end encryption (unimplemented). Fetched notes will be stored into
351    /// the client's store.
352    ///
353    /// An internal pagination mechanism is employed to reduce the number of downloaded notes: this
354    /// fetches only notes past the stored cursor. Historical notes for a newly tracked tag are
355    /// recovered automatically by [`Client::sync_note_transport`], which backfills each new tag.
356    pub async fn fetch_private_notes(&mut self) -> Result<(), ClientError> {
357        self.ensure_genesis_in_place().await?;
358
359        let note_tags: Vec<NoteTag> =
360            self.store.get_unique_note_tags().await?.into_iter().collect();
361        let cursor = self.store.get_note_transport_cursor().await?;
362
363        let mut id_by_commitment = BTreeMap::new();
364        let (note_files, new_cursor) =
365            self.fetch_transport_notes(cursor, &note_tags, &mut id_by_commitment).await?;
366
367        self.import_notes(&note_files).await?;
368        self.store.update_note_transport_cursor(new_cursor).await?;
369
370        Ok(())
371    }
372
373    /// Plans the backfill of historical private notes for tags added after the global cursor
374    /// 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.
387    ///
388    /// Returns the pruned covered set, whether pruning changed it, and the tags to backfill. Reads
389    /// only: persisting the covered set is left to the apply phase, which writes it after the
390    /// imported notes so a crash re-backfills instead of skipping a tag whose notes were never
391    /// written.
392    async fn plan_backfill(&self) -> Result<(BTreeSet<NoteTag>, bool, Vec<NoteTag>), ClientError> {
393        let candidates = self.backfill_candidate_tags().await?;
394        let loaded = self.load_covered_tags().await?;
395
396        // Drop tags no longer tracked. Keeping a removed tag marked covered would make a later
397        // re-add skip its backlog, silently missing notes that arrived while it was untracked.
398        let covered: BTreeSet<NoteTag> = loaded.intersection(&candidates).copied().collect();
399        let pruned = covered.len() != loaded.len();
400
401        let new_tags: Vec<NoteTag> = candidates
402            .difference(&covered)
403            .copied()
404            .take(Self::MAX_BACKFILL_TAGS_PER_SYNC)
405            .collect();
406
407        Ok((covered, pruned, new_tags))
408    }
409
410    /// Drain a single tag's full history from the transport, paging until the cursor stops
411    /// advancing. Uses a local cursor and never touches the global one, so it cannot regress
412    /// steady-state progress. Returns the note files from every fetched page, in page order and
413    /// none of them written.
414    async fn backfill_tag(
415        &self,
416        tag: NoteTag,
417        id_by_commitment: &mut BTreeMap<NoteDetailsCommitment, NoteId>,
418    ) -> Result<Vec<NoteFile>, ClientError> {
419        let mut note_files = Vec::new();
420        let mut cursor = NoteTransportCursor::init();
421        for _ in 0..Self::MAX_BACKFILL_ITERATIONS {
422            let (page_files, new_cursor) =
423                self.fetch_transport_notes(cursor, &[tag], id_by_commitment).await?;
424            note_files.extend(page_files);
425            // Terminate on any lack of forward progress. A well-behaved server returns `new_cursor
426            // == cursor` when there are no new notes for this tag (since `rcursor = max(cursor,
427            // max_seq_returned)`); using `<=` also handles implementations that return an `init()`
428            // cursor on empty batches (see the in-tree mock transport).
429
430            if new_cursor <= cursor {
431                return Ok(note_files);
432            }
433            cursor = new_cursor;
434        }
435
436        Err(ClientError::NoteTransportError(NoteTransportError::PaginationDidNotTerminate(
437            Self::MAX_BACKFILL_ITERATIONS,
438        )))
439    }
440
441    /// Screens the transport-delivered notes carrying a tag derived from a tracked account,
442    /// discarding those that no tracked account can consume. Notes carrying any other tag are kept
443    /// as delivered.
444    async fn screen_transport_notes(
445        &self,
446        notes: &mut Vec<(Note, Option<BlockNumber>)>,
447    ) -> Result<(), ClientError> {
448        let account_tags = self.tracked_account_tags().await?;
449
450        let notes_to_screen: Vec<Note> = notes
451            .iter()
452            .filter(|(note, _)| account_tags.contains(&note.metadata().tag()))
453            .map(|(note, _)| note.clone())
454            .collect();
455        let consumable = self.note_screener().get_batch_consumability(&notes_to_screen).await?;
456
457        // Discard the notes whose tag match the tracked accounts but are not consumable.
458        notes.retain(|(note, _)| {
459            !account_tags.contains(&note.metadata().tag()) || consumable.contains_key(&note.id())
460        });
461
462        Ok(())
463    }
464
465    /// Returns the tracked tags that were registered for an account, i.e. derived from its ID.
466    async fn tracked_account_tags(&self) -> Result<BTreeSet<NoteTag>, ClientError> {
467        let tags = self
468            .store
469            .get_note_tags()
470            .await?
471            .into_iter()
472            .filter(|record| matches!(record.source, NoteTagSource::Account(_)))
473            .map(|record| record.tag)
474            .collect();
475        Ok(tags)
476    }
477
478    /// Fetches and returns one batch of notes from the note transport layer for the provided tags
479    /// without applying any update to the store.
480    ///
481    /// The server paginates; this method issues one transport call and returns the note files
482    /// together with the new cursor. The returned cursor equals the input cursor when the batch was
483    /// empty (i.e. no new notes). Callers that want to drain a tag's full backlog should loop until
484    /// `new_cursor == cursor` (see [`Client::backfill_tag`]). Callers that do steady-state polling
485    /// (see [`Client::sync_state`] / [`Client::fetch_private_notes`]) should call this once per
486    /// tick with the stored cursor.
487    ///
488    /// Each downloaded note's id is recorded in `id_by_commitment` so the caller can resolve the
489    /// written records back to note ids once the final record set is known. Persistence of the
490    /// returned cursor is left to the caller so that drain loops can guard against regression of an
491    /// already-advanced stored cursor.
492    async fn fetch_transport_notes(
493        &self,
494        cursor: NoteTransportCursor,
495        tags: &[NoteTag],
496        id_by_commitment: &mut BTreeMap<NoteDetailsCommitment, NoteId>,
497    ) -> Result<(Vec<NoteFile>, NoteTransportCursor), ClientError> {
498        // Fallback lookback window, in blocks, used only for notes the transport delivered without
499        // a sender-provided block hint. Scanning back from sync height handles the race where a
500        // note is committed on-chain just before the NTL delivers its data. Without it,
501        // check_expected_notes would scan from sync_height forward and miss the already-committed
502        // note. A sender-provided hint is deterministic and always preferred.
503        const NOTE_LOOKBACK_BLOCKS: u32 = 20;
504
505        let mut notes = Vec::new();
506        // TODO: perhaps we should not need to map received IDs with details commitments, and
507        // instead we may allow `InputNoteRecord` to optionally keep NoteIds. Then within
508        // `import_note` we could match everything by ID and remove this map check
509        let (note_infos, rcursor) =
510            self.get_note_transport_api()?.fetch_notes(tags, cursor).await?;
511        for note_info in &note_infos {
512            // e2ee impl hint: for key in self.store.decryption_keys() try
513            // key.decrypt(details_bytes_encrypted)
514            //
515            // Drop invalid entries so the cursor can advance past them.
516            let note = match rejoin_note(&note_info.header, &note_info.details_bytes) {
517                Ok(note) => note,
518                Err(err) => {
519                    tracing::warn!(?err, "dropping malformed transport delivery");
520                    continue;
521                },
522            };
523            if !tags.contains(&note.metadata().tag()) {
524                tracing::warn!(
525                    tag = ?note.metadata().tag(),
526                    "dropping transport delivery for a tag that was not requested"
527                );
528                continue;
529            }
530
531            // The header carries the attachment-aware (on-chain) note id; the rejoined note has
532            // empty attachments and would hash to a different id, so key off the header.
533            id_by_commitment.insert(note.details_commitment(), note_info.header.id());
534
535            notes.push((note, note_info.block_hint));
536        }
537
538        // Screen the transport-delivered notes to discard the ones that are not relevant to the
539        // accounts tracked by the client. Boxed to avoid a `clippy::large_futures` warning, since
540        // the sync future is already close to the size limit.
541        Box::pin(self.screen_transport_notes(&mut notes)).await?;
542
543        self.drop_notes_processed_locally(&mut notes).await?;
544
545        let sync_height = self.get_sync_height().await?;
546        let fallback_after_block_num =
547            BlockNumber::from(sync_height.as_u32().saturating_sub(NOTE_LOOKBACK_BLOCKS));
548
549        let mut note_files = Vec::with_capacity(notes.len());
550        for (note, block_hint) in notes {
551            let tag = note.metadata().tag();
552            // Prefer the sender-provided hint, falling back to the lookback window when absent.
553            let after_block_num = block_hint.unwrap_or(fallback_after_block_num);
554            note_files.push(NoteFile::ExpectedNote {
555                details: note.into(),
556                sync_hint: NoteSyncHint::new(after_block_num, tag),
557            });
558        }
559
560        Ok((note_files, rcursor))
561    }
562
563    /// Fetches the notes the Note Transport Layer holds for the tracked tags.
564    ///
565    /// Runs the per-tag backfill and fetches a page of notes. This performs no node call and writes
566    /// nothing but the relay outbox, so it can run concurrently with the chain fetch. The caller
567    /// imports the returned files and then persists the cursor and the covered-tag set.
568    ///
569    /// Returns empty data when note transport is not configured.
570    pub(crate) async fn fetch_note_transport_updates(
571        &self,
572    ) -> Result<NoteTransportLayerUpdate, ClientError> {
573        let mut note_transport_update = NoteTransportLayerUpdate::default();
574        if !self.is_note_transport_enabled() {
575            return Ok(note_transport_update);
576        }
577
578        // Drain any private notes whose previous relay attempt failed. A flush error is logged, not
579        // propagated: a failing relay must not block the sync, and the entries stay durable for the
580        // next attempt. This is the one write this phase performs; it touches only the outbox
581        // setting, which is independent of everything the apply phase writes.
582        if let Err(err) = self.flush_relay_outbox().await {
583            tracing::warn!(?err, "relay outbox flush failed during sync; entries retained");
584        }
585
586        // Recover historical private notes for any tag added after the global cursor advanced. This
587        // drains each newly tracked tag from the start, fetching only that tag's own history.
588        let (mut covered, pruned, new_tags) = self.plan_backfill().await?;
589        let backfilled = !new_tags.is_empty();
590        for tag in new_tags {
591            note_transport_update
592                .note_files
593                .extend(self.backfill_tag(tag, &mut note_transport_update.id_by_commitment).await?);
594            covered.insert(tag);
595        }
596        if pruned || backfilled {
597            note_transport_update.covered_tags = Some(covered);
598        }
599
600        let cursor = self.store.get_note_transport_cursor().await?;
601        let note_tags: Vec<NoteTag> =
602            self.store.get_unique_note_tags().await?.into_iter().collect();
603        let (note_files, new_cursor) = self
604            .fetch_transport_notes(cursor, &note_tags, &mut note_transport_update.id_by_commitment)
605            .await?;
606        note_transport_update.note_files.extend(note_files);
607        note_transport_update.cursor = Some(new_cursor);
608
609        Ok(note_transport_update)
610    }
611
612    /// Writes everything [`Client::fetch_note_transport_updates`] returned, in three steps:
613    ///
614    /// 1. Imports the fetched notes, which resolves their on-chain state and stores the records.
615    /// 2. Saves the covered-tag set, when the backfill changed it.
616    /// 3. Advances the stored note transport cursor, when a page was fetched.
617    ///
618    /// The notes are written before the covered-tag set and the cursor, so a crash between them
619    /// re-fetches instead of skipping notes that were never written.
620    ///
621    /// Returns the ids of the imported notes and the details commitments of the records written.
622    pub(crate) async fn apply_note_transport_update(
623        &mut self,
624        update: NoteTransportLayerUpdate,
625    ) -> Result<(Vec<NoteId>, Vec<NoteDetailsCommitment>), ClientError> {
626        let NoteTransportLayerUpdate {
627            note_files,
628            id_by_commitment,
629            covered_tags,
630            cursor,
631        } = update;
632
633        let written = self.import_notes(&note_files).await?;
634        let mut imported_ids: Vec<NoteId> = written
635            .iter()
636            .filter_map(|commitment| id_by_commitment.get(commitment).copied())
637            .collect();
638
639        if let Some(covered_tags) = covered_tags {
640            self.save_covered_tags(&covered_tags).await?;
641        }
642
643        if let Some(cursor) = cursor {
644            self.store.update_note_transport_cursor(cursor).await?;
645        }
646
647        imported_ids.sort_unstable();
648        imported_ids.dedup();
649
650        Ok((imported_ids, written))
651    }
652
653    /// Drops deliveries of notes a local transaction is consuming; importing them would fail on the
654    /// no-overwrite-while-processing guard.
655    async fn drop_notes_processed_locally(
656        &self,
657        notes: &mut Vec<(Note, Option<BlockNumber>)>,
658    ) -> Result<(), ClientError> {
659        if notes.is_empty() {
660            return Ok(());
661        }
662
663        let commitments = notes.iter().map(|(note, _)| note.details_commitment()).collect();
664        let processing: BTreeSet<NoteDetailsCommitment> = self
665            .get_input_notes(NoteFilter::DetailsCommitments(commitments))
666            .await?
667            .into_iter()
668            .filter(InputNoteRecord::is_processing)
669            .map(|record| record.details_commitment())
670            .collect();
671
672        if !processing.is_empty() {
673            tracing::warn!(?processing, "skipping deliveries of notes being consumed locally");
674            notes.retain(|(note, _)| !processing.contains(&note.details_commitment()));
675        }
676        Ok(())
677    }
678}
679
680// NOTE TRANSPORT FETCH
681// ================================================================================================
682
683/// What the note transport fetch returned, before anything is written.
684///
685/// Built by [`Client::fetch_note_transport_updates`] and consumed by
686/// [`Client::apply_note_transport_update`].
687#[derive(Default)]
688pub(crate) struct NoteTransportLayerUpdate {
689    /// Notes to import, backfill pages first and then the steady-state page.
690    note_files: Vec<NoteFile>,
691    /// Note ids by details commitment, taken from the note headers the transport returned. Used to
692    /// resolve the written records back to ids.
693    id_by_commitment: BTreeMap<NoteDetailsCommitment, NoteId>,
694    /// Covered-tag set to persist, `None` when it did not change.
695    covered_tags: Option<BTreeSet<NoteTag>>,
696    /// New global cursor, from the steady-state page. `None` when no page was fetched.
697    cursor: Option<NoteTransportCursor>,
698}
699
700/// Note transport cursor
701///
702/// Pagination integer used to reduce the number of fetched notes from the note transport network,
703/// avoiding duplicate downloads.
704#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Eq, Ord)]
705pub struct NoteTransportCursor(u64);
706
707/// Note Transport update
708pub struct NoteTransportUpdate {
709    /// Pagination cursor for next fetch
710    pub cursor: NoteTransportCursor,
711    /// Fetched notes
712    pub notes: Vec<Note>,
713}
714
715impl NoteTransportCursor {
716    pub fn new(value: u64) -> Self {
717        Self(value)
718    }
719
720    pub fn init() -> Self {
721        Self::new(0)
722    }
723
724    pub fn value(&self) -> u64 {
725        self.0
726    }
727}
728
729impl From<u64> for NoteTransportCursor {
730    fn from(value: u64) -> Self {
731        Self::new(value)
732    }
733}
734
735/// The main transport client trait for sending and receiving encrypted notes
736#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
737#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
738pub trait NoteTransportClient: Send + Sync {
739    /// Send a note with optionally encrypted details
740    async fn send_note(
741        &self,
742        header: NoteHeader,
743        details: Vec<u8>,
744    ) -> Result<(), NoteTransportError>;
745
746    /// Send a note, relaying a block hint for the recipient's commitment scan.
747    ///
748    /// `block_hint` is the block from which the recipient should start scanning for the note's
749    /// commitment. The default implementation ignores it and delegates to
750    /// [`NoteTransportClient::send_note`], so existing implementors keep compiling. Transports that
751    /// can carry the hint (e.g. the gRPC client) override this.
752    async fn send_note_with_block_hint(
753        &self,
754        header: NoteHeader,
755        details: Vec<u8>,
756        _block_hint: BlockNumber,
757    ) -> Result<(), NoteTransportError> {
758        self.send_note(header, details).await
759    }
760
761    /// Fetch notes for given tags
762    ///
763    /// Downloads notes for given tags. Returns notes labelled after the provided cursor
764    /// (pagination), and an updated cursor.
765    async fn fetch_notes(
766        &self,
767        tag: &[NoteTag],
768        cursor: NoteTransportCursor,
769    ) -> Result<(Vec<NoteInfo>, NoteTransportCursor), NoteTransportError>;
770
771    /// Stream notes for a given tag
772    async fn stream_notes(
773        &self,
774        tag: NoteTag,
775        cursor: NoteTransportCursor,
776    ) -> Result<Box<dyn NoteStream>, NoteTransportError>;
777}
778
779/// Stream trait for note streaming
780pub trait NoteStream:
781    Stream<Item = Result<Vec<NoteInfo>, NoteTransportError>> + Send + Unpin
782{
783}
784
785/// Information about a note fetched from the note transport network
786#[derive(Debug, Clone)]
787pub struct NoteInfo {
788    /// Note header
789    pub header: NoteHeader,
790    /// Note details, can be encrypted
791    pub details_bytes: Vec<u8>,
792    /// Sender-provided block hint: the block from which the recipient should start scanning for the
793    /// note's on-chain commitment, instead of applying its default lookback window. `None` when the
794    /// sender did not provide a hint.
795    pub block_hint: Option<BlockNumber>,
796}
797
798impl NoteInfo {
799    /// Build a [`NoteInfo`] without a block hint (`block_hint` is `None`).
800    ///
801    /// Use the [`NoteInfo::block_hint`] field directly to attach a hint.
802    pub fn new(header: NoteHeader, details_bytes: Vec<u8>) -> Self {
803        Self { header, details_bytes, block_hint: None }
804    }
805}
806
807// SERIALIZATION
808// ================================================================================================
809
810impl Serializable for NoteInfo {
811    fn write_into<W: ByteWriter>(&self, target: &mut W) {
812        self.header.write_into(target);
813        self.details_bytes.write_into(target);
814        self.block_hint.write_into(target);
815    }
816}
817
818impl Deserializable for NoteInfo {
819    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
820        let header = NoteHeader::read_from(source)?;
821        let details_bytes = Vec::<u8>::read_from(source)?;
822        let block_hint = Option::<BlockNumber>::read_from(source)?;
823        Ok(NoteInfo { header, details_bytes, block_hint })
824    }
825}
826
827impl Serializable for NoteTransportCursor {
828    fn write_into<W: ByteWriter>(&self, target: &mut W) {
829        self.0.write_into(target);
830    }
831}
832
833impl Deserializable for NoteTransportCursor {
834    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
835        let value = u64::read_from(source)?;
836        Ok(Self::new(value))
837    }
838}
839
840fn rejoin_note(header: &NoteHeader, details_bytes: &[u8]) -> Result<Note, DeserializationError> {
841    let mut reader = SliceReader::new(details_bytes);
842    let details = NoteDetails::read_from(&mut reader)?;
843    // The header must commit to the delivered details.
844    if details.commitment() != header.details_commitment() {
845        return Err(DeserializationError::InvalidValue(format!(
846            "delivered note details (commitment {}) do not match the header's details commitment {}",
847            details.commitment().to_hex(),
848            header.details_commitment().to_hex(),
849        )));
850    }
851    // The transport wire format only carries `NoteHeader` + serialized `NoteDetails`, not the
852    // attachments collection. We rejoin with empty attachments; this matches the original note only
853    // when it had no attachments in the first place.
854    let partial_metadata = *header.metadata().partial_metadata();
855    Ok(Note::new(
856        details.assets().clone(),
857        partial_metadata,
858        details.recipient().clone(),
859    ))
860}