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, ¬e_tags, &mut id_by_commitment).await?;
366
367 self.import_notes(¬e_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(¬e.metadata().tag()))
453 .map(|(note, _)| note.clone())
454 .collect();
455 let consumable = self.note_screener().get_batch_consumability(¬es_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(¬e.metadata().tag()) || consumable.contains_key(¬e.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 ¬e_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(¬e_info.header, ¬e_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(¬e.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, ¬e_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(¬e_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(¬e.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}