Skip to main content

miden_client/rpc/domain/
note.rs

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