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 miden_protocol::address::Address;
13use miden_protocol::block::BlockNumber;
14use miden_protocol::note::{
15 Note,
16 NoteDetails,
17 NoteDetailsCommitment,
18 NoteHeader,
19 NoteId,
20 NoteInclusionProof,
21 NoteTag,
22};
23use miden_protocol::utils::serde::Serializable;
24use miden_tx::auth::TransactionAuthenticator;
25use miden_tx::utils::serde::{
26 ByteReader,
27 ByteWriter,
28 Deserializable,
29 DeserializationError,
30 SliceReader,
31};
32
33pub use self::errors::NoteTransportError;
34use crate::note::{NoteFile, NoteSyncHint};
35use crate::store::{NoteFilter, SettingScope};
36use crate::sync::NoteTagSource;
37use crate::{Client, ClientError};
38
39pub const NOTE_TRANSPORT_MAINNET_ENDPOINT: &str = "https://transport.mainnet.miden.io";
40pub const NOTE_TRANSPORT_TESTNET_ENDPOINT: &str = "https://transport.miden.io";
41pub const NOTE_TRANSPORT_DEVNET_ENDPOINT: &str = "https://transport.devnet.miden.io";
42#[deprecated(since = "0.17.1", note = "note transport does not read this setting")]
44pub const NOTE_TRANSPORT_CURSOR_STORE_SETTING: &str = "note_transport_cursor";
45#[deprecated(
48 since = "0.18.0",
49 note = "note transport stores one cursor under NOTE_TRANSPORT_SYNC_STATE_KEY"
50)]
51pub const NOTE_TRANSPORT_CURSORS_KEY: &str = "note_transport_cursors";
52pub const NOTE_TRANSPORT_SYNC_STATE_KEY: &str = "note_transport_sync_state";
54
55const MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST: usize = 128;
57
58#[deprecated(since = "0.17.1", note = "note transport no longer keeps per-tag backfill state")]
60pub const NOTE_TRANSPORT_COVERED_TAGS_KEY: &str = "note_transport_covered_tags";
61
62impl<AUTH> Client<AUTH> {
64 pub const MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST: usize = MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST;
66
67 #[deprecated(
71 since = "0.17.1",
72 note = "use MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST; account tags are no longer limited"
73 )]
74 pub const MAX_ACCOUNT_TAGS: usize = Self::MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST;
75
76 pub fn is_note_transport_enabled(&self) -> bool {
78 self.note_transport_api.is_some()
79 }
80
81 pub(crate) fn get_note_transport_api(
85 &self,
86 ) -> Result<Arc<dyn NoteTransportClient>, NoteTransportError> {
87 self.note_transport_api.clone().ok_or(NoteTransportError::Disabled)
88 }
89
90 pub async fn send_private_note_with_proof(
109 &mut self,
110 note: Note,
111 address: &Address,
112 inclusion_proof: NoteInclusionProof,
113 ) -> Result<(), ClientError> {
114 let api = self.get_note_transport_api()?;
115
116 let _ = address;
119
120 api.send_note_with_proof(TransportNote::from(note), inclusion_proof).await?;
121
122 Ok(())
123 }
124
125 #[allow(deprecated)]
130 async fn load_note_transport_sync_state(
131 &self,
132 ) -> Result<Option<NoteTransportSyncState>, ClientError> {
133 let get = |key: &str| self.store.get_setting(SettingScope::Client, key.into());
134 let state = if let Some(bytes) = get(NOTE_TRANSPORT_SYNC_STATE_KEY).await? {
135 NoteTransportSyncState::read_from_bytes(&bytes).map(Some)
136 } else if let Some(bytes) = get(NOTE_TRANSPORT_CURSORS_KEY).await? {
137 BTreeMap::<NoteTag, Option<NoteTransportCursor>>::read_from_bytes(&bytes)
138 .map(migrate_tag_cursors)
139 } else {
140 Ok(None)
141 };
142 Ok(state
143 .inspect_err(|err| tracing::warn!(?err, "resetting unreadable note transport state"))
144 .unwrap_or_default())
145 }
146}
147
148impl<AUTH> Client<AUTH>
149where
150 AUTH: TransactionAuthenticator + Sync + 'static,
151{
152 #[deprecated(since = "0.17.1", note = "note transport no longer performs per-tag backfills")]
154 pub const MAX_BACKFILL_TAGS_PER_SYNC: usize = 64;
155
156 pub async fn fetch_private_notes(&mut self) -> Result<(), ClientError> {
165 self.ensure_genesis_in_place().await?;
166
167 let mut update = self.fetch_transport_notes_in_chunks().await?;
168 let fetch_error = update.fetch_error.take();
169 self.apply_note_transport_update(update).await?;
170 if let Some(error) = fetch_error {
171 return Err(error);
172 }
173
174 Ok(())
175 }
176
177 async fn screen_transport_notes(
181 &self,
182 notes: &mut Vec<(NoteId, Note, Option<BlockNumber>)>,
183 ) -> Result<(), ClientError> {
184 let account_tags = self.tracked_account_tags().await?;
185
186 let notes_to_screen: Vec<Note> = notes
187 .iter()
188 .filter(|(_, note, _)| account_tags.contains(¬e.metadata().tag()))
189 .map(|(_, note, _)| note.clone())
190 .collect();
191 let consumable = self.note_screener().get_batch_consumability(¬es_to_screen).await?;
192
193 notes.retain(|(_, note, _)| {
195 !account_tags.contains(¬e.metadata().tag()) || consumable.contains_key(¬e.id())
196 });
197
198 Ok(())
199 }
200
201 async fn tracked_account_tags(&self) -> Result<BTreeSet<NoteTag>, ClientError> {
203 let tags = self
204 .store
205 .get_note_tags()
206 .await?
207 .into_iter()
208 .filter(|record| matches!(record.source, NoteTagSource::Account(_)))
209 .map(|record| record.tag)
210 .collect();
211 Ok(tags)
212 }
213
214 async fn fetch_transport_notes_in_chunks(
217 &self,
218 ) -> Result<NoteTransportLayerUpdate, ClientError> {
219 let api = self.get_note_transport_api()?;
220 let (start, stored_tags) = self.load_note_transport_sync_state().await?.unzip();
221 let (covered, added): (Vec<_>, Vec<_>) = self
222 .store
223 .get_unique_note_tags()
224 .await?
225 .into_iter()
226 .partition(|tag| stored_tags.as_ref().is_some_and(|tags| tags.contains(tag)));
227
228 let mut update = NoteTransportLayerUpdate::default();
229 let mut notes = Vec::new();
230 let mut sync_to = None;
231 let mut reached = BTreeSet::new();
232 let chunks = covered
233 .chunks(MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST)
234 .map(|chunk| (start, chunk))
235 .chain(added.chunks(MAX_NOTE_TAGS_PER_TRANSPORT_REQUEST).map(|chunk| (None, chunk)));
236 for (start, chunk) in chunks {
237 match fetch_transport_chunk(api.as_ref(), chunk, start, &mut sync_to, &mut notes).await
238 {
239 Ok(()) => reached.extend(chunk),
240 Err(error) => {
241 update.fetch_error.get_or_insert(error.into());
242 },
243 }
244 }
245
246 if let Some(cursor) = sync_to
248 && covered.iter().all(|tag| reached.contains(tag))
249 {
250 update.sync_state = Some((cursor, reached));
251 }
252 update.id_by_commitment =
253 notes.iter().map(|(id, note, _)| (note.details_commitment(), *id)).collect();
254 update.note_files = self.prepare_transport_notes(notes).await?;
255 Ok(update)
256 }
257
258 async fn prepare_transport_notes(
260 &self,
261 mut notes: Vec<(NoteId, Note, Option<BlockNumber>)>,
262 ) -> Result<Vec<NoteFile>, ClientError> {
263 const NOTE_LOOKBACK_BLOCKS: u32 = 20;
269
270 if notes.is_empty() {
271 return Ok(Vec::new());
272 }
273
274 self.drop_notes_resolved_locally(&mut notes).await?;
275
276 Box::pin(self.screen_transport_notes(&mut notes)).await?;
280
281 let sync_height = self.get_sync_height().await?;
282 let fallback_after_block_num =
283 BlockNumber::from(sync_height.as_u32().saturating_sub(NOTE_LOOKBACK_BLOCKS));
284
285 let mut note_files = Vec::with_capacity(notes.len());
286 for (_, note, block_hint) in notes {
287 let tag = note.metadata().tag();
288 let after_block_num = block_hint.unwrap_or(fallback_after_block_num);
290 note_files.push(NoteFile::ExpectedNote {
291 details: note.into(),
292 sync_hint: NoteSyncHint::new(after_block_num, tag),
293 });
294 }
295
296 Ok(note_files)
297 }
298
299 pub(crate) async fn fetch_note_transport_updates(
308 &self,
309 ) -> Result<NoteTransportLayerUpdate, ClientError> {
310 if !self.is_note_transport_enabled() {
311 return Ok(NoteTransportLayerUpdate::default());
312 }
313
314 self.fetch_transport_notes_in_chunks().await
315 }
316
317 pub(crate) async fn apply_note_transport_update(
327 &mut self,
328 update: NoteTransportLayerUpdate,
329 ) -> Result<(Vec<NoteId>, Vec<NoteDetailsCommitment>), ClientError> {
330 let NoteTransportLayerUpdate {
331 note_files,
332 id_by_commitment,
333 sync_state,
334 fetch_error,
335 } = update;
336
337 let written = self.import_notes(¬e_files).await?;
338 let mut imported_ids: Vec<NoteId> = written
339 .iter()
340 .filter_map(|commitment| id_by_commitment.get(commitment).copied())
341 .collect();
342
343 if let Some(state) = sync_state {
344 let key = String::from(NOTE_TRANSPORT_SYNC_STATE_KEY);
345 self.store.set_setting(SettingScope::Client, key, state.to_bytes()).await?;
346 }
347 if let Some(error) = fetch_error {
348 tracing::warn!(
349 ?error,
350 "note transport fetch failed; the next fetch reads the tags again"
351 );
352 }
353
354 imported_ids.sort_unstable();
355 imported_ids.dedup();
356
357 Ok((imported_ids, written))
358 }
359
360 async fn drop_notes_resolved_locally(
369 &self,
370 notes: &mut Vec<(NoteId, Note, Option<BlockNumber>)>,
371 ) -> Result<(), ClientError> {
372 let commitments = notes.iter().map(|(_, note, _)| note.details_commitment()).collect();
373 let records: BTreeMap<_, _> = self
374 .get_input_notes(NoteFilter::DetailsCommitments(commitments))
375 .await?
376 .into_iter()
377 .map(|record| (record.details_commitment(), record))
378 .collect();
379 notes.retain(|(id, note, _)| {
380 let Some(record) = records.get(¬e.details_commitment()) else {
381 return true;
382 };
383 if record.is_processing() {
384 tracing::warn!(%id, "skipping delivery of a note being consumed locally");
385 return false;
386 }
387 !((record.is_committed() || record.is_consumed()) && record.id() == Some(*id))
388 });
389 Ok(())
390 }
391}
392
393#[derive(Default)]
401pub(crate) struct NoteTransportLayerUpdate {
402 note_files: Vec<NoteFile>,
404 id_by_commitment: BTreeMap<NoteDetailsCommitment, NoteId>,
407 sync_state: Option<NoteTransportSyncState>,
409 pub(crate) fetch_error: Option<ClientError>,
411}
412
413type NoteTransportSyncState = (NoteTransportCursor, BTreeSet<NoteTag>);
415
416fn migrate_tag_cursors(
421 cursors: BTreeMap<NoteTag, Option<NoteTransportCursor>>,
422) -> Option<NoteTransportSyncState> {
423 let cursors: BTreeMap<_, _> =
424 cursors.into_iter().filter_map(|(tag, cursor)| Some((tag, cursor?))).collect();
425 let cursor = *cursors.values().min_by_key(|cursor| cursor.sequence)?;
426 cursors
427 .values()
428 .all(|other| other.nonce == cursor.nonce)
429 .then(|| (cursor, cursors.into_keys().collect()))
430}
431
432async fn fetch_transport_chunk(
436 api: &dyn NoteTransportClient,
437 tags: &[NoteTag],
438 start: Option<NoteTransportCursor>,
439 sync_to: &mut Option<NoteTransportCursor>,
440 notes: &mut Vec<(NoteId, Note, Option<BlockNumber>)>,
441) -> Result<(), NoteTransportError> {
442 let mut cursor = start;
443 loop {
445 let page = api.fetch_notes_page(tags, cursor, *sync_to).await?;
446 notes.extend(validate_transport_page(&page, tags, cursor, *sync_to)?);
447 sync_to.get_or_insert(page.sync_to);
448 if !page.has_more {
449 return Ok(());
450 }
451 cursor = Some(page.cursor);
452 }
453}
454
455fn validate_transport_page(
458 page: &NoteTransportPage,
459 tags: &[NoteTag],
460 request_cursor: Option<NoteTransportCursor>,
461 request_sync_to: Option<NoteTransportCursor>,
462) -> Result<Vec<(NoteId, Note, Option<BlockNumber>)>, NoteTransportError> {
463 let NoteTransportCursor { nonce, sequence } = page.cursor;
464 if request_sync_to.is_some_and(|bound| bound != page.sync_to)
465 || nonce != page.sync_to.nonce
466 || sequence > page.sync_to.sequence
467 {
468 return Err(NoteTransportError::Network(String::from(
469 "fetch response does not match the sync bound",
470 )));
471 }
472 if request_cursor.is_some_and(|request| {
473 nonce == request.nonce
474 && (sequence < request.sequence
475 || (!page.notes.is_empty() && sequence == request.sequence))
476 }) || (!page.notes.is_empty() && sequence == 0)
477 || (page.has_more && page.notes.is_empty())
478 {
479 return Err(NoteTransportError::Network(String::from(
480 "fetch response has invalid pagination progress",
481 )));
482 }
483 let mut notes = Vec::with_capacity(page.notes.len());
484 for info in &page.notes {
485 let note = rejoin_note(&info.header, &info.details_bytes)?;
486 if !tags.contains(¬e.metadata().tag()) {
487 return Err(NoteTransportError::UnrequestedTag(note.metadata().tag()));
488 }
489 notes.push((info.header.id(), note, info.block_hint));
491 }
492 Ok(notes)
493}
494
495#[derive(Clone, Copy, Debug, PartialEq, Eq)]
502pub struct NoteTransportCursor {
503 pub nonce: u64,
505 pub sequence: u64,
507}
508
509#[derive(Clone, Debug, PartialEq, Eq)]
514pub struct TransportNote {
515 header: NoteHeader,
516 details: NoteDetails,
517}
518
519impl TransportNote {
520 pub fn new(header: NoteHeader, details: NoteDetails) -> Result<Self, NoteTransportError> {
522 validate_note_parts(&header, &details)?;
523 Ok(Self { header, details })
524 }
525
526 pub fn header(&self) -> &NoteHeader {
528 &self.header
529 }
530
531 pub fn details(&self) -> &NoteDetails {
533 &self.details
534 }
535
536 pub fn into_parts(self) -> (NoteHeader, NoteDetails) {
538 (self.header, self.details)
539 }
540}
541
542impl From<Note> for TransportNote {
543 fn from(note: Note) -> Self {
544 let header = *note.header();
545 let details = NoteDetails::from(note);
546 Self { header, details }
547 }
548}
549
550#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
552#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
553pub trait NoteTransportClient: Send + Sync {
554 async fn send_note_with_proof(
559 &self,
560 note: TransportNote,
561 inclusion_proof: NoteInclusionProof,
562 ) -> Result<(), NoteTransportError>;
563
564 async fn fetch_notes_page(
570 &self,
571 tags: &[NoteTag],
572 cursor: Option<NoteTransportCursor>,
573 sync_to: Option<NoteTransportCursor>,
574 ) -> Result<NoteTransportPage, NoteTransportError>;
575}
576
577pub struct NoteTransportPage {
579 pub notes: Vec<NoteInfo>,
581 pub cursor: NoteTransportCursor,
583 pub has_more: bool,
585 pub sync_to: NoteTransportCursor,
588}
589
590#[derive(Debug, Clone)]
592pub struct NoteInfo {
593 pub header: NoteHeader,
595 pub details_bytes: Vec<u8>,
597 pub block_hint: Option<BlockNumber>,
601}
602
603impl NoteInfo {
604 pub fn new(header: NoteHeader, details_bytes: Vec<u8>) -> Self {
608 Self { header, details_bytes, block_hint: None }
609 }
610}
611
612impl Serializable for TransportNote {
616 fn write_into<W: ByteWriter>(&self, target: &mut W) {
617 self.header.write_into(target);
618 self.details.to_bytes().write_into(target);
619 }
620}
621
622impl Deserializable for TransportNote {
623 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
624 let header = NoteHeader::read_from(source)?;
625 let details_bytes = Vec::<u8>::read_from(source)?;
626 let details = NoteDetails::read_from_bytes(&details_bytes)?;
627 Self::new(header, details)
628 .map_err(|error| DeserializationError::InvalidValue(format!("{error}")))
629 }
630}
631
632impl Serializable for NoteInfo {
633 fn write_into<W: ByteWriter>(&self, target: &mut W) {
634 self.header.write_into(target);
635 self.details_bytes.write_into(target);
636 self.block_hint.write_into(target);
637 }
638}
639
640impl Deserializable for NoteInfo {
641 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
642 let header = NoteHeader::read_from(source)?;
643 let details_bytes = Vec::<u8>::read_from(source)?;
644 let block_hint = Option::<BlockNumber>::read_from(source)?;
645 Ok(NoteInfo { header, details_bytes, block_hint })
646 }
647}
648
649impl Serializable for NoteTransportCursor {
650 fn write_into<W: ByteWriter>(&self, target: &mut W) {
651 (self.nonce, self.sequence).write_into(target);
652 }
653}
654
655impl Deserializable for NoteTransportCursor {
656 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
657 let (nonce, sequence) = <(u64, u64)>::read_from(source)?;
658 Ok(Self { nonce, sequence })
659 }
660}
661
662fn rejoin_note(header: &NoteHeader, details_bytes: &[u8]) -> Result<Note, NoteTransportError> {
663 let mut reader = SliceReader::new(details_bytes);
664 let details = NoteDetails::read_from(&mut reader)?;
665 validate_note_parts(header, &details)?;
666 let partial_metadata = *header.metadata().partial_metadata();
670 Ok(Note::new(
671 details.assets().clone(),
672 partial_metadata,
673 details.recipient().clone(),
674 ))
675}
676
677pub(crate) fn validate_note_parts(
679 header: &NoteHeader,
680 details: &NoteDetails,
681) -> Result<(), NoteTransportError> {
682 let header_commitment = header.details_commitment();
683 let details_commitment = details.commitment();
684 if header_commitment != details_commitment {
685 return Err(NoteTransportError::NoteDetailsMismatch {
686 header: header_commitment,
687 details: details_commitment,
688 });
689 }
690
691 Ok(())
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 fn cursor(sequence: u64) -> NoteTransportCursor {
699 NoteTransportCursor { nonce: 1, sequence }
700 }
701
702 fn empty_page(cursor: NoteTransportCursor, has_more: bool) -> NoteTransportPage {
703 NoteTransportPage {
704 notes: Vec::new(),
705 cursor,
706 has_more,
707 sync_to: self::cursor(20),
708 }
709 }
710
711 #[test]
712 fn transport_rejects_invalid_pagination() {
713 for page in [empty_page(cursor(9), false), empty_page(cursor(10), true)] {
714 assert!(validate_transport_page(&page, &[], Some(cursor(10)), None).is_err());
715 }
716 }
717
718 #[test]
719 fn transport_rejects_page_outside_sync_bound() {
720 let other_database = NoteTransportCursor { nonce: 2, sequence: 5 };
721 for (page, request_sync_to) in [
722 (empty_page(cursor(10), false), Some(cursor(21))),
724 (empty_page(cursor(21), false), None),
726 (empty_page(other_database, false), None),
728 ] {
729 assert!(
730 validate_transport_page(&page, &[], Some(cursor(10)), request_sync_to).is_err()
731 );
732 }
733 }
734
735 #[test]
736 fn transport_accepts_pages_within_sync_bound() {
737 let page = empty_page(cursor(10), false);
738 assert!(validate_transport_page(&page, &[], Some(cursor(10)), Some(cursor(20))).is_ok());
739
740 let other_database = NoteTransportCursor { nonce: 2, sequence: 50 };
742 let page = empty_page(cursor(3), false);
743 assert!(validate_transport_page(&page, &[], Some(other_database), None).is_ok());
744 }
745
746 #[test]
747 fn transport_reads_legacy_tag_cursors() {
748 let tag = NoteTag::new(1);
749 let legacy = BTreeMap::from([(tag, Some((1u64, 4u64)))]).to_bytes();
750
751 let cursors =
752 BTreeMap::<NoteTag, Option<NoteTransportCursor>>::read_from_bytes(&legacy).unwrap();
753
754 assert_eq!(cursors, BTreeMap::from([(tag, Some(cursor(4)))]));
755 }
756
757 #[test]
758 fn transport_migrates_tag_cursors_to_lowest_cursor() {
759 let [first, second, third] = [1, 2, 3].map(NoteTag::new);
760 let cursors =
761 BTreeMap::from([(first, Some(cursor(7))), (second, Some(cursor(4))), (third, None)]);
762
763 let state = migrate_tag_cursors(cursors);
764
765 assert_eq!(state, Some((cursor(4), BTreeSet::from([first, second]))));
766 }
767
768 #[test]
769 fn transport_migration_drops_cursors_from_different_databases() {
770 let other_database = NoteTransportCursor { nonce: 2, sequence: 4 };
771 let cursors = BTreeMap::from([
772 (NoteTag::new(1), Some(cursor(7))),
773 (NoteTag::new(2), Some(other_database)),
774 ]);
775
776 assert_eq!(migrate_tag_cursors(cursors), None);
777 assert_eq!(migrate_tag_cursors(BTreeMap::new()), None);
778 }
779}