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