Skip to main content

miden_client/rpc/domain/
note.rs

1use alloc::collections::BTreeMap;
2use alloc::format;
3use alloc::vec::Vec;
4
5use miden_objects::DecodeMessageExt;
6use miden_protocol::account::AccountId;
7use miden_protocol::block::{BlockHeader, BlockNumber};
8use miden_protocol::crypto::SequentialCommit;
9use miden_protocol::crypto::merkle::MerklePath;
10use miden_protocol::note::{
11    Note,
12    NoteAttachment,
13    NoteAttachmentHeader,
14    NoteAttachmentScheme,
15    NoteAttachments,
16    NoteDetails,
17    NoteId,
18    NoteInclusionProof,
19    NoteMetadata,
20    NoteTag,
21    NoteType,
22    PartialNoteMetadata,
23};
24use miden_protocol::{Felt, Word};
25
26use super::{MissingFieldHelper, RpcConversionError};
27use crate::rpc::{RpcError, generated as proto};
28
29/// Reads a note ID off the wire. A free function because both types are foreign, so there can be no
30/// `TryFrom` impl.
31pub(crate) fn note_id_from_proto(value: proto::note::NoteId) -> Result<NoteId, RpcConversionError> {
32    Ok(value.decode_and_verify()?)
33}
34
35/// Reads a note inclusion proof and the ID of the note it proves.
36pub(crate) fn note_inclusion_proof_from_proto(
37    value: proto::note::NoteInclusionProof,
38) -> Result<(NoteId, NoteInclusionProof), RpcConversionError> {
39    Ok(value.decode_and_verify()?)
40}
41
42/// Aggregates individual attachment commitments into the note's attachments commitment.
43///
44/// The element layout mirrors [`NoteAttachments`]' own sequential commitment, so this yields the
45/// same value as the full attachments would, which is what lets commitment-only records work.
46struct AttachmentCommitments<'a>(&'a [Word]);
47
48impl SequentialCommit for AttachmentCommitments<'_> {
49    type Commitment = Word;
50
51    fn to_elements(&self) -> Vec<Felt> {
52        let mut elements = Vec::with_capacity(self.0.len() * miden_protocol::WORD_SIZE);
53        for commitment in self.0 {
54            elements.extend_from_slice(commitment.as_elements());
55        }
56        elements
57    }
58}
59
60/// A single attachment as a `SyncNotes` record reports it: verbatim when the node sent its content,
61/// commitment-only otherwise.
62#[derive(Debug)]
63enum ReportedAttachment {
64    /// The attachment arrived verbatim, so its content is known.
65    Full(NoteAttachment),
66    /// The attachment arrived as its commitment only.
67    Commitment(Word),
68}
69
70impl ReportedAttachment {
71    /// Returns the attachment's commitment.
72    fn to_commitment(&self) -> Word {
73        match self {
74            Self::Full(attachment) => attachment.to_commitment(),
75            Self::Commitment(commitment) => *commitment,
76        }
77    }
78}
79
80/// The attachments of a note as a `SyncNotes` record reports them. Both variants determine the
81/// note's attachments commitment exactly, but only one carries the content.
82#[derive(Debug)]
83enum ReportedAttachments {
84    /// Every attachment arrived verbatim, so the content is known and needs no fetching.
85    Full(NoteAttachments),
86    /// At least one attachment arrived as a commitment only. Holds one commitment per attachment,
87    /// enough to rebuild the note's metadata but not its content.
88    Commitments(Vec<Word>),
89}
90
91impl ReportedAttachments {
92    /// Aggregates the per-attachment reports of one record. The content is reconstructible only as
93    /// a whole set, so a single commitment-only attachment demotes the entire report to
94    /// commitments.
95    fn from_reports(reports: Vec<ReportedAttachment>) -> Result<Self, RpcConversionError> {
96        let commitments: Vec<Word> =
97            reports.iter().map(ReportedAttachment::to_commitment).collect();
98        let contents: Option<Vec<NoteAttachment>> = reports
99            .into_iter()
100            .map(|report| match report {
101                ReportedAttachment::Full(attachment) => Some(attachment),
102                ReportedAttachment::Commitment(_) => None,
103            })
104            .collect();
105
106        match contents {
107            Some(contents) => {
108                let attachments = NoteAttachments::new(contents).map_err(|err| {
109                    RpcConversionError::InvalidField(format!("attachments: {err}"))
110                })?;
111                Ok(Self::Full(attachments))
112            },
113            None => Ok(Self::Commitments(commitments)),
114        }
115    }
116
117    /// Returns the note's attachments commitment.
118    fn to_commitment(&self) -> Word {
119        match self {
120            Self::Full(attachments) => attachments.to_commitment(),
121            Self::Commitments(commitments) => AttachmentCommitments(commitments).to_commitment(),
122        }
123    }
124
125    /// Consumes the report and returns the content, when the record carried all of it.
126    fn into_content(self) -> Option<NoteAttachments> {
127        match self {
128            Self::Full(attachments) => Some(attachments),
129            Self::Commitments(_) => None,
130        }
131    }
132}
133
134/// The note metadata reconstructed from a `SyncNotes` record, together with what that record
135/// reported about the note's attachments.
136#[derive(Debug)]
137struct SyncNoteMetadata {
138    /// The note's full metadata, equal to what the on-chain note commits to.
139    metadata: NoteMetadata,
140    /// What the record reported about the note's attachments.
141    attachments: ReportedAttachments,
142}
143
144impl TryFrom<proto::rpc::NoteSyncMetadata> for SyncNoteMetadata {
145    type Error = RpcConversionError;
146
147    fn try_from(value: proto::rpc::NoteSyncMetadata) -> Result<Self, Self::Error> {
148        // The sync record spreads the canonical metadata fields over its own message, so they are
149        // gathered back into that message and verified as a whole.
150        let partial_metadata: PartialNoteMetadata = proto::note::PartialNoteMetadata {
151            version: value.version,
152            sender: value.sender,
153            note_type: value.note_type,
154            tag: value.tag,
155        }
156        .decode_and_verify()?;
157
158        if value.attachments.len() > NoteAttachments::MAX_COUNT {
159            return Err(RpcConversionError::InvalidField(format!(
160                "attachments length {} exceeds NoteAttachments::MAX_COUNT",
161                value.attachments.len(),
162            )));
163        }
164
165        let mut attachment_headers = [NoteAttachmentHeader::absent(); NoteAttachments::MAX_COUNT];
166        let mut reports = Vec::with_capacity(value.attachments.len());
167
168        for (slot, attachment) in value.attachments.into_iter().enumerate() {
169            let raw_scheme = u16::try_from(attachment.scheme).map_err(|_| {
170                RpcConversionError::InvalidField(format!(
171                    "attachments[{slot}].scheme={} does not fit in u16",
172                    attachment.scheme,
173                ))
174            })?;
175            let scheme = NoteAttachmentScheme::new(raw_scheme).map_err(|err| {
176                RpcConversionError::InvalidField(format!("attachments[{slot}].scheme: {err}"))
177            })?;
178            attachment_headers[slot] = NoteAttachmentHeader::new(scheme);
179
180            let payload = attachment.payload.ok_or_else(|| {
181                proto::rpc::NoteSyncAttachment::missing_field(stringify!(payload))
182            })?;
183            // An attachment that fits in a single word is sent verbatim, so it can be rebuilt in
184            // full. A larger one is sent as a commitment to keep the sync response bounded. The
185            // node may send a commitment even for a single-word one, so the choice is read off the
186            // payload variant and never inferred from a word count.
187            let report = match payload {
188                proto::rpc::note_sync_attachment::Payload::Value(value) => {
189                    ReportedAttachment::Full(NoteAttachment::with_word(
190                        scheme,
191                        Word::try_from(value)?,
192                    ))
193                },
194                proto::rpc::note_sync_attachment::Payload::Commitment(commitment) => {
195                    ReportedAttachment::Commitment(Word::try_from(commitment)?)
196                },
197            };
198            reports.push(report);
199        }
200
201        let attachments = ReportedAttachments::from_reports(reports)?;
202
203        Ok(SyncNoteMetadata {
204            metadata: NoteMetadata::from_parts(
205                partial_metadata,
206                attachment_headers,
207                attachments.to_commitment(),
208            ),
209            attachments,
210        })
211    }
212}
213
214// SYNC NOTE
215// ================================================================================================
216
217/// Represents a single block's worth of note sync data from the `SyncNotesResponse`.
218#[derive(Debug, Clone)]
219pub struct SyncNotesBlock {
220    /// Block header containing the matching notes.
221    pub block_header: BlockHeader,
222    /// MMR path for verifying the block's inclusion in the MMR at `block_to`.
223    pub mmr_path: MerklePath,
224    /// Notes matching the requested tags in this block, keyed by note ID.
225    pub notes: BTreeMap<NoteId, CommittedNote>,
226}
227
228impl TryFrom<proto::rpc::sync_notes_response::NoteSyncBlock> for SyncNotesBlock {
229    type Error = RpcError;
230
231    fn try_from(
232        block: proto::rpc::sync_notes_response::NoteSyncBlock,
233    ) -> Result<Self, Self::Error> {
234        let block_header: BlockHeader = block
235            .block_header
236            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(blocks.block_header)))?
237            .decode_and_build_unchecked()?;
238
239        let mmr_path: MerklePath = block
240            .mmr_path
241            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(blocks.mmr_path)))?
242            .decode_and_verify()?;
243
244        let notes: BTreeMap<NoteId, CommittedNote> = block
245            .notes
246            .into_iter()
247            .map(|n| {
248                let note = CommittedNote::try_from(n)?;
249                Ok((*note.note_id(), note))
250            })
251            .collect::<Result<_, RpcConversionError>>()?;
252
253        Ok(SyncNotesBlock { block_header, mmr_path, notes })
254    }
255}
256
257// SYNCED NOTE
258// ================================================================================================
259
260/// A block's worth of notes resolved by
261/// [`NodeRpcClient::sync_notes_with_content`](crate::rpc::NodeRpcClient::sync_notes_with_content).
262///
263/// Unlike [`SyncNotesBlock`] (the raw `SyncNotes` response), each note here also carries its
264/// attachments and, for a fetched public note, its details, so no re-joining by note ID is needed.
265#[derive(Debug, Clone)]
266pub struct ResolvedSyncNotesBlock {
267    /// Block header containing the matching notes.
268    pub block_header: BlockHeader,
269    /// MMR path for verifying the block's inclusion in the MMR at `block_to`.
270    pub mmr_path: MerklePath,
271    /// Notes matching the requested tags in this block, keyed by note ID.
272    pub notes: BTreeMap<NoteId, SyncedNote>,
273}
274
275/// Everything resolved about a single note during a notes sync: its identity, metadata, and
276/// inclusion proof (always present, from `SyncNotes`), its attachments, and the public note body
277/// when it was fetched via `GetNotesById`.
278#[derive(Debug, Clone)]
279pub struct SyncedNote {
280    /// Note ID of the synced note, as reported by `SyncNotes`.
281    pub note_id: NoteId,
282    /// The note's full metadata, as reported by `SyncNotes`.
283    pub metadata: NoteMetadata,
284    /// Inclusion proof for the note in the block, as reported by `SyncNotes`.
285    pub inclusion_proof: NoteInclusionProof,
286    /// The public note's body, fetched via `GetNotesById`. `None` for a private note, and for a
287    /// public note whose body was not requested or not returned.
288    pub details: Option<NoteDetails>,
289    /// The note's attachments, either carried in full by the sync record or fetched via
290    /// `GetNotesById`. Empty for a note whose metadata advertises none.
291    pub attachments: NoteAttachments,
292}
293
294impl SyncedNote {
295    /// Pairs a sync record with the content resolved for it, checking that the content is
296    /// consistent with the record:
297    ///
298    /// - Only a public note can have a body. The converse is not checked, since a public note
299    ///   legitimately has no body whenever its body was not requested.
300    /// - The attachments must hash to the metadata's attachments commitment. This also catches a
301    ///   note advertising attachments whose content never arrived, which would be unconsumable.
302    ///
303    /// Both sides of that check come from the node, so it is a consistency check between its
304    /// responses. The note is authenticated by a consumer recomputing its id and inclusion proof.
305    ///
306    /// A rejection concerns a single note, not the response as a whole:
307    /// [`NodeRpcClient::sync_notes_with_content`](crate::rpc::NodeRpcClient::sync_notes_with_content)
308    /// skips the offending note with a warning instead of failing the sync, since content
309    /// availability can be influenced by the note's creator.
310    pub fn new(
311        committed: CommittedNote,
312        details: Option<NoteDetails>,
313        attachments: NoteAttachments,
314    ) -> Result<Self, RpcError> {
315        if details.is_some() && committed.note_type() != NoteType::Public {
316            return Err(RpcError::InvalidResponse(format!(
317                "a note body was returned for private note {}",
318                committed.note_id()
319            )));
320        }
321
322        if attachments.to_commitment() != committed.metadata().attachments_commitment() {
323            return Err(RpcError::InvalidResponse(format!(
324                "the attachments resolved for note {} do not match the note's attachments \
325                 commitment",
326                committed.note_id()
327            )));
328        }
329
330        let CommittedNote {
331            note_id,
332            metadata,
333            inclusion_proof,
334            attachments: _,
335        } = committed;
336
337        Ok(Self {
338            note_id,
339            metadata,
340            inclusion_proof,
341            details,
342            attachments,
343        })
344    }
345
346    /// Returns the number of the block in which the note was committed.
347    pub fn block_num(&self) -> BlockNumber {
348        self.inclusion_proof.location().block_num()
349    }
350
351    /// Consumes the synced note and returns its sync record together with the attachment content
352    /// resolved for it. The note body, which the record does not hold, is dropped.
353    ///
354    /// The returned record reports its attachments as resolved, so
355    /// [`CommittedNote::needs_attachment_fetch`] is always `false` for it. A note without
356    /// attachments carries an empty set.
357    pub fn into_committed_note(self) -> CommittedNote {
358        CommittedNote {
359            note_id: self.note_id,
360            metadata: self.metadata,
361            inclusion_proof: self.inclusion_proof,
362            attachments: Some(self.attachments),
363        }
364    }
365}
366
367// COMMITTED NOTE
368// ================================================================================================
369
370/// Represents a committed note, returned as part of a `SyncNotesResponse`.
371#[derive(Debug, Clone)]
372pub struct CommittedNote {
373    /// Note ID of the committed note.
374    note_id: NoteId,
375    /// Note metadata. Sync responses always carry the full [`NoteMetadata`]: header fields plus
376    /// attachment scheme markers and the attachments commitment.
377    metadata: NoteMetadata,
378    /// Inclusion proof for the note in the block.
379    inclusion_proof: NoteInclusionProof,
380    /// The note's attachment content, when the source reporting the note carried every attachment
381    /// verbatim. See [`CommittedNote::attachments`].
382    attachments: Option<NoteAttachments>,
383}
384
385impl CommittedNote {
386    pub fn new(
387        note_id: NoteId,
388        metadata: NoteMetadata,
389        inclusion_proof: NoteInclusionProof,
390    ) -> Self {
391        Self {
392            note_id,
393            metadata,
394            inclusion_proof,
395            attachments: None,
396        }
397    }
398
399    /// Records the note's attachment content, for a source that reports every attachment verbatim.
400    ///
401    /// # Errors
402    ///
403    /// Returns an error if the content does not hash to the metadata's attachments commitment. Such
404    /// content would turn [`CommittedNote::needs_attachment_fetch`] off for a note whose real
405    /// content was never obtained, leaving it to be dropped for good by the consistency check in
406    /// [`SyncedNote::new`] instead of being fetched.
407    pub fn with_attachments(
408        mut self,
409        attachments: NoteAttachments,
410    ) -> Result<Self, RpcConversionError> {
411        if attachments.to_commitment() != self.metadata.attachments_commitment() {
412            return Err(RpcConversionError::InvalidField(format!(
413                "attachments recorded for note {} do not match its attachments commitment",
414                self.note_id,
415            )));
416        }
417
418        self.attachments = Some(attachments);
419        Ok(self)
420    }
421
422    pub fn note_id(&self) -> &NoteId {
423        &self.note_id
424    }
425
426    pub fn note_type(&self) -> NoteType {
427        self.metadata.note_type()
428    }
429
430    pub fn tag(&self) -> NoteTag {
431        self.metadata.tag()
432    }
433
434    pub fn sender(&self) -> AccountId {
435        self.metadata.sender()
436    }
437
438    /// Returns the full note metadata.
439    pub fn metadata(&self) -> &NoteMetadata {
440        &self.metadata
441    }
442
443    /// Returns `true` if the note's metadata advertises at least one attachment.
444    pub fn has_attachments(&self) -> bool {
445        self.metadata.has_attachments()
446    }
447
448    /// Returns the note's attachment content, `Some` when the reporting source carried every
449    /// attachment verbatim.
450    ///
451    /// `None` means at least one attachment must be fetched via `GetNotesById`, or that the source
452    /// reports no attachment content at all, as `SyncTransactions` inclusion proofs do.
453    pub fn attachments(&self) -> Option<&NoteAttachments> {
454        self.attachments.as_ref()
455    }
456
457    /// Returns `true` if the note's attachment content has to be fetched via `GetNotesById`: its
458    /// metadata advertises attachments and the source reporting the note did not carry them all.
459    pub fn needs_attachment_fetch(&self) -> bool {
460        self.has_attachments() && self.attachments.is_none()
461    }
462
463    pub fn inclusion_proof(&self) -> &NoteInclusionProof {
464        &self.inclusion_proof
465    }
466
467    /// Returns the number of the block in which the note was committed.
468    pub fn block_num(&self) -> BlockNumber {
469        self.inclusion_proof.location().block_num()
470    }
471}
472
473impl TryFrom<proto::rpc::NoteSyncRecord> for CommittedNote {
474    type Error = RpcConversionError;
475
476    fn try_from(note: proto::rpc::NoteSyncRecord) -> Result<Self, Self::Error> {
477        let proto_metadata = note
478            .metadata
479            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(notes.metadata)))?;
480        let SyncNoteMetadata { metadata, attachments } = proto_metadata.try_into()?;
481
482        let proto_inclusion_proof = note.inclusion_proof.ok_or(
483            proto::rpc::SyncNotesResponse::missing_field(stringify!(notes.inclusion_proof)),
484        )?;
485
486        let (note_id, inclusion_proof) = note_inclusion_proof_from_proto(proto_inclusion_proof)?;
487
488        let committed = CommittedNote::new(note_id, metadata, inclusion_proof);
489
490        match attachments.into_content() {
491            Some(attachments) => committed.with_attachments(attachments),
492            None => Ok(committed),
493        }
494    }
495}
496
497// FETCHED NOTE
498// ================================================================================================
499
500/// Describes the possible responses from the `GetNotesById` endpoint for a single note.
501#[allow(clippy::large_enum_variant)]
502pub enum FetchedNote {
503    /// Details for a private note include its ID, metadata, attachments and inclusion proof. Other
504    /// details needed to consume the note are expected to be stored locally, off-chain.
505    ///
506    /// Attachments are a public extension of the note and are stored on-chain even for private
507    /// notes, so the node returns them here; they are needed to reconstruct the correct note ID.
508    Private(NoteId, NoteMetadata, NoteAttachments, NoteInclusionProof),
509    /// Contains the full [`Note`] object alongside its [`NoteInclusionProof`].
510    Public(Note, NoteInclusionProof),
511}
512
513impl FetchedNote {
514    /// Returns the note's inclusion details.
515    pub fn inclusion_proof(&self) -> &NoteInclusionProof {
516        match self {
517            FetchedNote::Private(_, _, _, inclusion_proof)
518            | FetchedNote::Public(_, inclusion_proof) => inclusion_proof,
519        }
520    }
521
522    /// Returns the note's metadata.
523    pub fn metadata(&self) -> &NoteMetadata {
524        match self {
525            FetchedNote::Private(_, metadata, ..) => metadata,
526            FetchedNote::Public(note, _) => note.metadata(),
527        }
528    }
529
530    /// Returns the note's attachments.
531    pub fn attachments(&self) -> &NoteAttachments {
532        match self {
533            FetchedNote::Private(_, _, attachments, _) => attachments,
534            FetchedNote::Public(note, _) => note.attachments(),
535        }
536    }
537
538    /// Returns the note's ID.
539    pub fn id(&self) -> NoteId {
540        match self {
541            FetchedNote::Private(note_id, ..) => *note_id,
542            FetchedNote::Public(note, _) => note.id(),
543        }
544    }
545}
546
547impl TryFrom<proto::rpc::CommittedNote> for FetchedNote {
548    type Error = RpcConversionError;
549
550    fn try_from(value: proto::rpc::CommittedNote) -> Result<Self, Self::Error> {
551        let proto_inclusion_proof = value
552            .inclusion_proof
553            .ok_or_else(|| proto::rpc::CommittedNote::missing_field(stringify!(inclusion_proof)))?;
554        let (note_id, inclusion_proof) = note_inclusion_proof_from_proto(proto_inclusion_proof)?;
555
556        let note = value
557            .note
558            .ok_or_else(|| proto::rpc::CommittedNote::missing_field(stringify!(note)))?;
559
560        let partial_metadata: PartialNoteMetadata = note
561            .metadata
562            .ok_or_else(|| proto::rpc::CommittedNote::missing_field(stringify!(note.metadata)))?
563            .decode_and_verify()?;
564
565        // The note type decides which variant the response describes. The details are checked
566        // against it, since a note is not usable when the two disagree.
567        match partial_metadata.note_type() {
568            NoteType::Public => {
569                if note.note_details.is_none() {
570                    return Err(RpcConversionError::InvalidField(format!(
571                        "no note details were returned for public note {note_id}"
572                    )));
573                }
574
575                Ok(FetchedNote::Public(note.decode_and_verify()?, inclusion_proof))
576            },
577            NoteType::Private => {
578                if note.note_details.is_some() {
579                    return Err(RpcConversionError::InvalidField(format!(
580                        "note details were returned for private note {note_id}"
581                    )));
582                }
583
584                let attachments: NoteAttachments = note
585                    .note_attachments
586                    .ok_or_else(|| {
587                        proto::rpc::CommittedNote::missing_field(stringify!(note.note_attachments))
588                    })?
589                    .decode_and_verify()?;
590                let metadata = NoteMetadata::new(partial_metadata, &attachments);
591
592                Ok(FetchedNote::Private(note_id, metadata, attachments, inclusion_proof))
593            },
594        }
595    }
596}
597
598// TESTS
599// ================================================================================================
600
601#[cfg(test)]
602mod tests {
603    use miden_protocol::account::{AccountIdVersion, AccountType, AssetCallbackFlag};
604    use miden_protocol::crypto::merkle::SparseMerklePath;
605    use miden_protocol::note::{NoteAssets, NoteRecipient, NoteStorage};
606    use miden_standards::code_builder::CodeBuilder;
607
608    use super::*;
609
610    fn sender() -> AccountId {
611        AccountId::dummy(
612            [1; 15],
613            AccountIdVersion::Version1,
614            AccountType::Public,
615            AssetCallbackFlag::Disabled,
616        )
617    }
618
619    fn single_word_attachment(scheme: u16, word: u32) -> NoteAttachment {
620        NoteAttachment::with_word(
621            NoteAttachmentScheme::new(scheme).unwrap(),
622            Word::from([word, word, word, word]),
623        )
624    }
625
626    fn multi_word_attachment(scheme: u16) -> NoteAttachment {
627        NoteAttachment::with_words(
628            NoteAttachmentScheme::new(scheme).unwrap(),
629            vec![Word::from([5u32, 6, 7, 8]), Word::from([9u32, 10, 11, 12])],
630        )
631        .unwrap()
632    }
633
634    /// Builds the sync record a node sends for `attachments`, then decodes it the way
635    /// [`CommittedNote`] does.
636    fn decode_sync_metadata(attachments: &NoteAttachments) -> SyncNoteMetadata {
637        sync_metadata(sync_attachments(attachments)).try_into().unwrap()
638    }
639
640    fn bare_committed_note(metadata: NoteMetadata) -> CommittedNote {
641        let path = SparseMerklePath::from_parts(0, Vec::new()).unwrap();
642        let inclusion_proof =
643            NoteInclusionProof::new(BlockNumber::GENESIS, 0, path).expect("index 0 is in range");
644
645        CommittedNote::new(NoteId::from_raw(Word::empty()), metadata, inclusion_proof)
646    }
647
648    fn committed_note(decoded: SyncNoteMetadata) -> CommittedNote {
649        let committed = bare_committed_note(decoded.metadata);
650
651        match decoded.attachments.into_content() {
652            Some(attachments) => committed.with_attachments(attachments).unwrap(),
653            None => committed,
654        }
655    }
656
657    /// Encodes attachments the way the node does in a sync response: single-word attachments carry
658    /// their value, larger ones only their commitment.
659    fn sync_attachments(attachments: &NoteAttachments) -> Vec<proto::rpc::NoteSyncAttachment> {
660        attachments
661            .iter()
662            .map(|attachment| {
663                let payload = if attachment.num_words() == 1 {
664                    proto::rpc::note_sync_attachment::Payload::Value(
665                        attachment.content().as_words()[0].into(),
666                    )
667                } else {
668                    proto::rpc::note_sync_attachment::Payload::Commitment(
669                        attachment.to_commitment().into(),
670                    )
671                };
672
673                proto::rpc::NoteSyncAttachment {
674                    scheme: u32::from(attachment.attachment_scheme().as_u16()),
675                    payload: Some(payload),
676                }
677            })
678            .collect()
679    }
680
681    fn sync_metadata(
682        attachments: Vec<proto::rpc::NoteSyncAttachment>,
683    ) -> proto::rpc::NoteSyncMetadata {
684        proto::rpc::NoteSyncMetadata {
685            sender: Some(sender().into()),
686            note_type: proto::note::NoteType::from(NoteType::Private) as i32,
687            tag: 7,
688            attachments,
689            version: proto::note::NoteVersion::V1 as i32,
690        }
691    }
692
693    #[test]
694    fn sync_metadata_reconstructs_metadata_with_mixed_attachments() {
695        let attachments =
696            NoteAttachments::new(vec![single_word_attachment(42, 1), multi_word_attachment(100)])
697                .unwrap();
698
699        let expected = NoteMetadata::new(
700            PartialNoteMetadata::new(sender(), NoteType::Private).with_tag(NoteTag::new(7)),
701            &attachments,
702        );
703
704        assert_eq!(decode_sync_metadata(&attachments).metadata, expected);
705    }
706
707    #[test]
708    fn sync_metadata_reconstructs_metadata_without_attachments() {
709        let attachments = NoteAttachments::empty();
710        let expected = NoteMetadata::new(
711            PartialNoteMetadata::new(sender(), NoteType::Private).with_tag(NoteTag::new(7)),
712            &attachments,
713        );
714
715        let decoded: SyncNoteMetadata = sync_metadata(Vec::new()).try_into().unwrap();
716
717        assert_eq!(decoded.metadata, expected);
718    }
719
720    /// A record whose every attachment fits in a single word describes the note's attachments in
721    /// full, so no `GetNotesById` request is needed to obtain them.
722    #[test]
723    fn sync_metadata_reports_attachments_sent_verbatim() {
724        let attachments = NoteAttachments::new(vec![
725            single_word_attachment(42, 1),
726            single_word_attachment(64, 2),
727        ])
728        .unwrap();
729
730        let decoded = decode_sync_metadata(&attachments);
731
732        let committed = committed_note(decoded);
733        assert_eq!(committed.attachments(), Some(&attachments));
734        assert!(!committed.needs_attachment_fetch());
735    }
736
737    /// A full set leaves no trailing slot absent, so it is the only case where the positional
738    /// header fill writes every slot.
739    #[test]
740    fn sync_metadata_reconstructs_a_full_attachment_set() {
741        let attachments = NoteAttachments::new(
742            (0..NoteAttachments::MAX_COUNT)
743                .map(|i| {
744                    let scheme = u16::try_from(i).unwrap() + 42;
745                    single_word_attachment(scheme, u32::try_from(i).unwrap() + 1)
746                })
747                .collect(),
748        )
749        .unwrap();
750
751        let expected = NoteMetadata::new(
752            PartialNoteMetadata::new(sender(), NoteType::Private).with_tag(NoteTag::new(7)),
753            &attachments,
754        );
755        let decoded = decode_sync_metadata(&attachments);
756
757        assert_eq!(decoded.metadata, expected);
758        let committed = committed_note(decoded);
759        assert_eq!(committed.attachments(), Some(&attachments));
760        assert!(!committed.needs_attachment_fetch());
761    }
762
763    /// A note with no attachments has nothing to fetch, and its (empty) attachments are known.
764    #[test]
765    fn sync_metadata_reports_empty_attachments() {
766        let decoded: SyncNoteMetadata = sync_metadata(Vec::new()).try_into().unwrap();
767
768        let committed = committed_note(decoded);
769        assert_eq!(committed.attachments(), Some(&NoteAttachments::empty()));
770        assert!(!committed.needs_attachment_fetch());
771    }
772
773    /// One attachment sent as a commitment withholds the whole set, since the attachments can only
774    /// be rebuilt as a whole.
775    #[test]
776    fn sync_metadata_withholds_partially_reported_attachments() {
777        let attachments =
778            NoteAttachments::new(vec![single_word_attachment(42, 1), multi_word_attachment(100)])
779                .unwrap();
780
781        let decoded = decode_sync_metadata(&attachments);
782
783        assert!(matches!(decoded.attachments, ReportedAttachments::Commitments(_)));
784        assert!(committed_note(decoded).needs_attachment_fetch());
785    }
786
787    /// The node may send a commitment even for a single-word attachment, so availability follows
788    /// the payload variant alone, never a word count.
789    #[test]
790    fn sync_metadata_withholds_single_word_attachment_sent_as_commitment() {
791        let attachment = single_word_attachment(42, 1);
792        let proto_attachments = vec![proto::rpc::NoteSyncAttachment {
793            scheme: u32::from(attachment.attachment_scheme().as_u16()),
794            payload: Some(proto::rpc::note_sync_attachment::Payload::Commitment(
795                attachment.to_commitment().into(),
796            )),
797        }];
798        let attachments = NoteAttachments::new(vec![attachment]).unwrap();
799
800        let decoded: SyncNoteMetadata = sync_metadata(proto_attachments).try_into().unwrap();
801
802        // The metadata is still reconstructed exactly, only the content is missing.
803        assert_eq!(
804            decoded.metadata,
805            NoteMetadata::new(
806                PartialNoteMetadata::new(sender(), NoteType::Private).with_tag(NoteTag::new(7)),
807                &attachments,
808            )
809        );
810        assert!(matches!(decoded.attachments, ReportedAttachments::Commitments(_)));
811        assert!(committed_note(decoded).needs_attachment_fetch());
812    }
813
814    #[test]
815    fn sync_metadata_rejects_too_many_attachments() {
816        let attachment = proto::rpc::NoteSyncAttachment {
817            scheme: 42,
818            payload: Some(proto::rpc::note_sync_attachment::Payload::Value(Word::empty().into())),
819        };
820        let attachments = vec![attachment; NoteAttachments::MAX_COUNT + 1];
821
822        let err = SyncNoteMetadata::try_from(sync_metadata(attachments)).unwrap_err();
823
824        assert!(matches!(err, RpcConversionError::InvalidField(_)), "got {err:?}");
825    }
826
827    #[test]
828    fn sync_metadata_rejects_reserved_absent_scheme() {
829        let attachments = vec![proto::rpc::NoteSyncAttachment {
830            scheme: 0,
831            payload: Some(proto::rpc::note_sync_attachment::Payload::Value(Word::empty().into())),
832        }];
833
834        let err = SyncNoteMetadata::try_from(sync_metadata(attachments)).unwrap_err();
835
836        assert!(matches!(err, RpcConversionError::InvalidField(_)), "got {err:?}");
837    }
838
839    #[test]
840    fn sync_metadata_rejects_missing_attachment_payload() {
841        let attachments = vec![proto::rpc::NoteSyncAttachment { scheme: 42, payload: None }];
842
843        let err = SyncNoteMetadata::try_from(sync_metadata(attachments)).unwrap_err();
844
845        assert!(
846            matches!(err, RpcConversionError::MissingFieldInProtobufRepresentation { .. }),
847            "got {err:?}"
848        );
849    }
850
851    #[test]
852    fn sync_metadata_rejects_an_unusable_note_version() {
853        for version in [proto::note::NoteVersion::Unspecified as i32, 999] {
854            let mut wire = sync_metadata(Vec::new());
855            wire.version = version;
856
857            let err = SyncNoteMetadata::try_from(wire).unwrap_err();
858
859            assert!(matches!(err, RpcConversionError::CanonicalConversion(_)), "got {err:?}");
860        }
861    }
862
863    #[test]
864    fn synced_note_rejects_a_body_for_a_private_note() {
865        let decoded: SyncNoteMetadata = sync_metadata(Vec::new()).try_into().unwrap();
866        let committed = committed_note(decoded);
867
868        let note_script = CodeBuilder::new()
869            .compile_note_script("@note_script\npub proc main\n    nop\nend")
870            .unwrap();
871        let recipient =
872            NoteRecipient::new(Word::empty(), note_script, NoteStorage::new(vec![]).unwrap());
873        let details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), recipient);
874
875        let err = SyncedNote::new(committed, Some(details), NoteAttachments::empty()).unwrap_err();
876
877        assert!(matches!(err, RpcError::InvalidResponse(_)), "got {err:?}");
878    }
879
880    /// A note advertising attachments whose content never arrived is rejected: empty attachments
881    /// hash to a different commitment than the note's.
882    #[test]
883    fn synced_note_rejects_unresolved_attachments() {
884        let attachments = NoteAttachments::new(vec![multi_word_attachment(100)]).unwrap();
885        let committed = committed_note(decode_sync_metadata(&attachments));
886
887        let err = SyncedNote::new(committed, None, NoteAttachments::empty()).unwrap_err();
888
889        assert!(matches!(err, RpcError::InvalidResponse(_)), "got {err:?}");
890    }
891}