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 hashing this
137/// yields the same value as the full attachments would, without needing their contents.
138// TODO: single-word attachment payloads now arrive inline in the sync response, so some note data
139// may be derived without a `GetNotesById` request
140// https://github.com/0xMiden/rust-sdk/issues/2360
141struct AttachmentCommitments(Vec<Word>);
142
143impl SequentialCommit for AttachmentCommitments {
144    type Commitment = Word;
145
146    fn to_elements(&self) -> Vec<Felt> {
147        let mut elements = Vec::with_capacity(self.0.len() * miden_protocol::WORD_SIZE);
148        for commitment in &self.0 {
149            elements.extend_from_slice(commitment.as_elements());
150        }
151        elements
152    }
153}
154
155impl TryFrom<proto::note::NoteSyncMetadata> for NoteMetadata {
156    type Error = RpcConversionError;
157
158    fn try_from(value: proto::note::NoteSyncMetadata) -> Result<Self, Self::Error> {
159        let sender = value
160            .sender
161            .ok_or_else(|| proto::note::NoteSyncMetadata::missing_field(stringify!(sender)))?
162            .try_into()?;
163        let note_type = note_type_from_proto(value.note_type)?;
164        let tag = NoteTag::new(value.tag);
165        let partial_metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag);
166
167        if value.attachments.len() > NoteAttachments::MAX_COUNT {
168            return Err(RpcConversionError::InvalidField(format!(
169                "attachments length {} exceeds NoteAttachments::MAX_COUNT",
170                value.attachments.len(),
171            )));
172        }
173
174        let mut attachment_headers = [NoteAttachmentHeader::absent(); NoteAttachments::MAX_COUNT];
175        let mut commitments = Vec::with_capacity(value.attachments.len());
176
177        for (slot, attachment) in value.attachments.into_iter().enumerate() {
178            let raw_scheme = u16::try_from(attachment.scheme).map_err(|_| {
179                RpcConversionError::InvalidField(format!(
180                    "attachments[{slot}].scheme={} does not fit in u16",
181                    attachment.scheme,
182                ))
183            })?;
184            let scheme = NoteAttachmentScheme::new(raw_scheme).map_err(|err| {
185                RpcConversionError::InvalidField(format!("attachments[{slot}].scheme: {err}"))
186            })?;
187            attachment_headers[slot] = NoteAttachmentHeader::new(scheme);
188
189            let payload = attachment.payload.ok_or_else(|| {
190                proto::note::NoteSyncAttachment::missing_field(stringify!(payload))
191            })?;
192            // Single-word attachments are sent verbatim, so their commitment is derived locally;
193            // larger ones are sent as commitments to keep the sync response bounded.
194            // TODO: the verbatim word is discarded here, but it could be kept to derive note data
195            // without a `GetNotesById` request
196            // https://github.com/0xMiden/rust-sdk/issues/2360
197            let commitment = match payload {
198                proto::note::note_sync_attachment::Payload::Value(value) => {
199                    NoteAttachment::with_word(scheme, Word::try_from(value)?).to_commitment()
200                },
201                proto::note::note_sync_attachment::Payload::Commitment(commitment) => {
202                    Word::try_from(commitment)?
203                },
204            };
205            commitments.push(commitment);
206        }
207
208        let attachments_commitment = AttachmentCommitments(commitments).to_commitment();
209
210        Ok(NoteMetadata::from_parts(
211            partial_metadata,
212            attachment_headers,
213            attachments_commitment,
214        ))
215    }
216}
217
218impl TryFrom<&proto::note::NoteMetadata> for PartialNoteMetadata {
219    type Error = RpcConversionError;
220
221    fn try_from(value: &proto::note::NoteMetadata) -> Result<Self, Self::Error> {
222        let sender = value
223            .sender
224            .clone()
225            .ok_or_else(|| proto::note::NoteMetadata::missing_field(stringify!(sender)))?
226            .try_into()?;
227        let note_type = note_type_from_proto(value.note_type)?;
228        let tag = NoteTag::new(value.tag);
229
230        Ok(PartialNoteMetadata::new(sender, note_type).with_tag(tag))
231    }
232}
233
234impl From<NoteMetadata> for proto::note::NoteMetadata {
235    fn from(value: NoteMetadata) -> Self {
236        proto::note::NoteMetadata {
237            sender: Some(value.sender().into()),
238            note_type: note_type_to_proto(value.note_type()),
239            tag: value.tag().as_u32(),
240            attachment_schemes: attachment_schemes_to_proto(value.attachment_headers()),
241            attachments_commitment: Some(value.attachments_commitment().into()),
242        }
243    }
244}
245
246impl TryFrom<proto::note::NoteHeader> for NoteHeader {
247    type Error = RpcConversionError;
248
249    fn try_from(value: proto::note::NoteHeader) -> Result<Self, Self::Error> {
250        let details_commitment_word: Word = value
251            .details_commitment
252            .ok_or(proto::note::NoteHeader::missing_field(stringify!(details_commitment)))?
253            .try_into()?;
254        let metadata = value
255            .metadata
256            .ok_or(proto::note::NoteHeader::missing_field(stringify!(metadata)))?
257            .try_into()?;
258        Ok(NoteHeader::new(
259            NoteDetailsCommitment::from_raw(details_commitment_word),
260            metadata,
261        ))
262    }
263}
264
265impl TryFrom<proto::note::NoteInclusionInBlockProof> for NoteInclusionProof {
266    type Error = RpcConversionError;
267
268    fn try_from(value: proto::note::NoteInclusionInBlockProof) -> Result<Self, Self::Error> {
269        Ok(NoteInclusionProof::new(
270            value.block_num.into(),
271            u16::try_from(value.note_index_in_block)
272                .map_err(|_| RpcConversionError::InvalidField("NoteIndexInBlock".into()))?,
273            value
274                .inclusion_path
275                .ok_or_else(|| {
276                    proto::note::NoteInclusionInBlockProof::missing_field(stringify!(
277                        inclusion_path
278                    ))
279                })?
280                .try_into()?,
281        )?)
282    }
283}
284
285// SYNC NOTE
286// ================================================================================================
287
288/// Represents a single block's worth of note sync data from the `SyncNotesResponse`.
289#[derive(Debug, Clone)]
290pub struct SyncNotesBlock {
291    /// Block header containing the matching notes.
292    pub block_header: BlockHeader,
293    /// MMR path for verifying the block's inclusion in the MMR at `block_to`.
294    pub mmr_path: MerklePath,
295    /// Notes matching the requested tags in this block, keyed by note ID.
296    pub notes: BTreeMap<NoteId, CommittedNote>,
297}
298
299impl TryFrom<proto::rpc::sync_notes_response::NoteSyncBlock> for SyncNotesBlock {
300    type Error = RpcError;
301
302    fn try_from(
303        block: proto::rpc::sync_notes_response::NoteSyncBlock,
304    ) -> Result<Self, Self::Error> {
305        let block_header = block
306            .block_header
307            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(blocks.block_header)))?
308            .try_into()?;
309
310        let mmr_path = block
311            .mmr_path
312            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(blocks.mmr_path)))?
313            .try_into()?;
314
315        let notes: BTreeMap<NoteId, CommittedNote> = block
316            .notes
317            .into_iter()
318            .map(|n| {
319                let note = CommittedNote::try_from(n)?;
320                Ok((*note.note_id(), note))
321            })
322            .collect::<Result<_, RpcConversionError>>()?;
323
324        Ok(SyncNotesBlock { block_header, mmr_path, notes })
325    }
326}
327
328// SYNCED NOTE
329// ================================================================================================
330
331/// A block's worth of notes resolved by
332/// [`NodeRpcClient::sync_notes_with_content`](crate::rpc::NodeRpcClient::sync_notes_with_content).
333///
334/// Unlike [`SyncNotesBlock`] (the raw `SyncNotes` response), each note here also carries the body
335/// and attachment content fetched via `GetNotesById`, so a consumer never has to re-join two
336/// parallel collections by note ID.
337#[derive(Debug, Clone)]
338pub struct ResolvedSyncNotesBlock {
339    /// Block header containing the matching notes.
340    pub block_header: BlockHeader,
341    /// MMR path for verifying the block's inclusion in the MMR at `block_to`.
342    pub mmr_path: MerklePath,
343    /// Notes matching the requested tags in this block, keyed by note ID.
344    pub notes: BTreeMap<NoteId, SyncedNote>,
345}
346
347/// Everything resolved about a single note during a notes sync: its identity, metadata, and
348/// inclusion proof (always present, from `SyncNotes`), plus any body or attachment content
349/// fetched via `GetNotesById`.
350#[derive(Debug, Clone)]
351pub struct SyncedNote {
352    /// Note identity, metadata, and inclusion proof, as reported by `SyncNotes`.
353    pub committed: CommittedNote,
354    /// Body and/or attachment content resolved via `GetNotesById`; `None` if none was fetched
355    /// (plain private notes, or public notes when bodies were not requested).
356    pub content: Option<ResolvedNoteContent>,
357}
358
359impl SyncedNote {
360    /// Pairs a sync record with the content resolved for it, checking that the content is
361    /// consistent with the record's metadata:
362    ///
363    /// - The content variant must match the record's note type.
364    /// - Resolved attachments must hash to the metadata's attachments commitment — the metadata is
365    ///   what inclusion-proof verification later authenticates, so this binds the fetched bytes to
366    ///   the on-chain note.
367    /// - A note whose metadata advertises attachments must have resolved content: storing such a
368    ///   note without its attachment content would leave it unconsumable with no retry path once
369    ///   its expected-note tag is dropped.
370    ///
371    /// A rejection concerns a single note, not the response as a whole:
372    /// [`NodeRpcClient::sync_notes_with_content`](crate::rpc::NodeRpcClient::sync_notes_with_content)
373    /// skips the offending note with a warning instead of failing the sync, since content
374    /// availability can be influenced by the note's creator.
375    pub fn new(
376        committed: CommittedNote,
377        content: Option<ResolvedNoteContent>,
378    ) -> Result<Self, RpcError> {
379        match &content {
380            Some(resolved) => {
381                let expected_note_type = match resolved {
382                    ResolvedNoteContent::Public { .. } => NoteType::Public,
383                    ResolvedNoteContent::Private { .. } => NoteType::Private,
384                };
385                if committed.note_type() != expected_note_type {
386                    return Err(RpcError::InvalidResponse(format!(
387                        "content returned for note {} does not match the note's type",
388                        committed.note_id()
389                    )));
390                }
391
392                if resolved.attachments().to_commitment()
393                    != committed.metadata().attachments_commitment()
394                {
395                    return Err(RpcError::InvalidResponse(format!(
396                        "attachment content returned for note {} does not match the note's \
397                         attachments commitment",
398                        committed.note_id()
399                    )));
400                }
401            },
402            None => {
403                if committed.has_attachments() {
404                    return Err(RpcError::InvalidResponse(format!(
405                        "note {} advertises attachments but the node did not return their content",
406                        committed.note_id()
407                    )));
408                }
409            },
410        }
411
412        Ok(Self { committed, content })
413    }
414}
415
416/// Body and attachment content fetched for a note via `GetNotesById`.
417#[derive(Debug, Clone)]
418#[allow(clippy::large_enum_variant)]
419pub enum ResolvedNoteContent {
420    /// Content fetched for a public note.
421    Public {
422        /// The public note body (recipient and assets, without metadata).
423        details: NoteDetails,
424        /// The note's attachment content. May be empty for a public note that carries none.
425        attachments: NoteAttachments,
426    },
427    /// Content fetched for a private note. Private notes expose no on-chain body, so only their
428    /// attachment content is resolved.
429    Private {
430        /// The note's attachment content.
431        attachments: NoteAttachments,
432    },
433}
434
435impl ResolvedNoteContent {
436    /// Returns the attachment content fetched for the note.
437    pub fn attachments(&self) -> &NoteAttachments {
438        match self {
439            Self::Public { attachments, .. } | Self::Private { attachments } => attachments,
440        }
441    }
442
443    /// Consumes the content and returns the attachment content fetched for the note.
444    pub fn into_attachments(self) -> NoteAttachments {
445        match self {
446            Self::Public { attachments, .. } | Self::Private { attachments } => attachments,
447        }
448    }
449}
450
451// COMMITTED NOTE
452// ================================================================================================
453
454/// Represents a committed note, returned as part of a `SyncNotesResponse`.
455#[derive(Debug, Clone)]
456pub struct CommittedNote {
457    /// Note ID of the committed note.
458    note_id: NoteId,
459    /// Note metadata. Sync responses always carry the full [`NoteMetadata`] (header fields plus
460    /// attachment scheme markers and the attachments commitment); attachment **content** is
461    /// fetched separately via `GetNotesById`.
462    metadata: NoteMetadata,
463    /// Inclusion proof for the note in the block.
464    inclusion_proof: NoteInclusionProof,
465}
466
467impl CommittedNote {
468    pub fn new(
469        note_id: NoteId,
470        metadata: NoteMetadata,
471        inclusion_proof: NoteInclusionProof,
472    ) -> Self {
473        Self { note_id, metadata, inclusion_proof }
474    }
475
476    pub fn note_id(&self) -> &NoteId {
477        &self.note_id
478    }
479
480    pub fn note_type(&self) -> NoteType {
481        self.metadata.note_type()
482    }
483
484    pub fn tag(&self) -> NoteTag {
485        self.metadata.tag()
486    }
487
488    pub fn sender(&self) -> AccountId {
489        self.metadata.sender()
490    }
491
492    /// Returns the full note metadata.
493    pub fn metadata(&self) -> &NoteMetadata {
494        &self.metadata
495    }
496
497    /// Returns `true` if the note's metadata advertises at least one attachment.
498    ///
499    /// Sync records carry attachment scheme markers (not the attachment content), so a present
500    /// scheme in any header slot indicates the note has attachments whose content must be fetched
501    /// separately via `GetNotesById`.
502    pub fn has_attachments(&self) -> bool {
503        self.metadata
504            .attachment_headers()
505            .iter()
506            .any(|header| header.scheme().is_some())
507    }
508
509    pub fn inclusion_proof(&self) -> &NoteInclusionProof {
510        &self.inclusion_proof
511    }
512
513    /// Returns the number of the block in which the note was committed.
514    pub fn block_num(&self) -> BlockNumber {
515        self.inclusion_proof.location().block_num()
516    }
517}
518
519impl TryFrom<proto::note::NoteSyncRecord> for CommittedNote {
520    type Error = RpcConversionError;
521
522    fn try_from(note: proto::note::NoteSyncRecord) -> Result<Self, Self::Error> {
523        let proto_metadata = note
524            .metadata
525            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(notes.metadata)))?;
526        let metadata: NoteMetadata = proto_metadata.try_into()?;
527
528        let proto_inclusion_proof = note.inclusion_proof.ok_or(
529            proto::rpc::SyncNotesResponse::missing_field(stringify!(notes.inclusion_proof)),
530        )?;
531
532        let note_id: NoteId = proto_inclusion_proof
533            .note_id
534            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(
535                notes.inclusion_proof.note_id
536            )))?
537            .try_into()?;
538
539        let inclusion_proof: NoteInclusionProof = proto_inclusion_proof.try_into()?;
540
541        Ok(CommittedNote::new(note_id, metadata, inclusion_proof))
542    }
543}
544
545// FETCHED NOTE
546// ================================================================================================
547
548/// Describes the possible responses from the `GetNotesById` endpoint for a single note.
549#[allow(clippy::large_enum_variant)]
550pub enum FetchedNote {
551    /// Details for a private note include its ID, metadata, attachments and inclusion proof. Other
552    /// details needed to consume the note are expected to be stored locally, off-chain.
553    ///
554    /// Attachments are a public extension of the note and are stored on-chain even for private
555    /// notes, so the node returns them here; they are needed to reconstruct the correct note ID.
556    Private(NoteId, NoteMetadata, NoteAttachments, NoteInclusionProof),
557    /// Contains the full [`Note`] object alongside its [`NoteInclusionProof`].
558    Public(Note, NoteInclusionProof),
559}
560
561impl FetchedNote {
562    /// Returns the note's inclusion details.
563    pub fn inclusion_proof(&self) -> &NoteInclusionProof {
564        match self {
565            FetchedNote::Private(_, _, _, inclusion_proof)
566            | FetchedNote::Public(_, inclusion_proof) => inclusion_proof,
567        }
568    }
569
570    /// Returns the note's metadata.
571    pub fn metadata(&self) -> &NoteMetadata {
572        match self {
573            FetchedNote::Private(_, metadata, ..) => metadata,
574            FetchedNote::Public(note, _) => note.metadata(),
575        }
576    }
577
578    /// Returns the note's attachments.
579    pub fn attachments(&self) -> &NoteAttachments {
580        match self {
581            FetchedNote::Private(_, _, attachments, _) => attachments,
582            FetchedNote::Public(note, _) => note.attachments(),
583        }
584    }
585
586    /// Returns the note's ID.
587    pub fn id(&self) -> NoteId {
588        match self {
589            FetchedNote::Private(note_id, ..) => *note_id,
590            FetchedNote::Public(note, _) => note.id(),
591        }
592    }
593}
594
595impl TryFrom<proto::note::CommittedNote> for FetchedNote {
596    type Error = RpcConversionError;
597
598    fn try_from(value: proto::note::CommittedNote) -> Result<Self, Self::Error> {
599        let inclusion_proof = value.inclusion_proof.ok_or_else(|| {
600            proto::note::CommittedNote::missing_field(stringify!(inclusion_proof))
601        })?;
602
603        let note_id: NoteId = inclusion_proof
604            .note_id
605            .ok_or_else(|| {
606                proto::note::CommittedNote::missing_field(stringify!(inclusion_proof.note_id))
607            })?
608            .try_into()?;
609
610        let inclusion_proof = NoteInclusionProof::try_from(inclusion_proof)?;
611
612        let note = value
613            .note
614            .ok_or_else(|| proto::note::CommittedNote::missing_field(stringify!(note)))?;
615
616        let proto_metadata = note
617            .metadata
618            .ok_or_else(|| proto::note::CommittedNote::missing_field(stringify!(note.metadata)))?;
619        let metadata: NoteMetadata = proto_metadata.clone().try_into()?;
620        let partial_metadata: PartialNoteMetadata = (&proto_metadata).try_into()?;
621
622        let attachments = if note.attachments.is_empty() {
623            NoteAttachments::empty()
624        } else {
625            NoteAttachments::read_from_bytes(&note.attachments)?
626        };
627
628        if let Some(detail_bytes) = note.details {
629            let details = NoteDetails::read_from_bytes(&detail_bytes)?;
630            let (assets, recipient) = details.into_parts();
631
632            Ok(FetchedNote::Public(
633                Note::with_attachments(assets, partial_metadata, recipient, attachments),
634                inclusion_proof,
635            ))
636        } else {
637            Ok(FetchedNote::Private(note_id, metadata, attachments, inclusion_proof))
638        }
639    }
640}
641
642// NOTE SCRIPT
643// ================================================================================================
644
645impl TryFrom<proto::note::NoteScript> for NoteScript {
646    type Error = RpcConversionError;
647
648    fn try_from(note_script: proto::note::NoteScript) -> Result<Self, Self::Error> {
649        let mast_forest = MastForest::read_from_bytes(&note_script.mast)?;
650        let entrypoint = MastNodeId::from_u32_safe(note_script.entrypoint, &mast_forest)?;
651        Ok(NoteScript::from_parts(alloc::sync::Arc::new(mast_forest), entrypoint))
652    }
653}
654
655// TESTS
656// ================================================================================================
657
658#[cfg(test)]
659mod tests {
660    use miden_protocol::account::{AccountIdVersion, AccountType, AssetCallbackFlag};
661
662    use super::*;
663
664    fn sender() -> AccountId {
665        AccountId::dummy(
666            [1; 15],
667            AccountIdVersion::Version1,
668            AccountType::Public,
669            AssetCallbackFlag::Disabled,
670        )
671    }
672
673    /// Encodes attachments the way the node does in a sync response: single-word attachments carry
674    /// their value, larger ones only their commitment.
675    fn sync_attachments(attachments: &NoteAttachments) -> Vec<proto::note::NoteSyncAttachment> {
676        attachments
677            .iter()
678            .map(|attachment| {
679                let payload = if attachment.num_words() == 1 {
680                    proto::note::note_sync_attachment::Payload::Value(
681                        attachment.content().as_words()[0].into(),
682                    )
683                } else {
684                    proto::note::note_sync_attachment::Payload::Commitment(
685                        attachment.to_commitment().into(),
686                    )
687                };
688
689                proto::note::NoteSyncAttachment {
690                    scheme: u32::from(attachment.attachment_scheme().as_u16()),
691                    payload: Some(payload),
692                }
693            })
694            .collect()
695    }
696
697    fn sync_metadata(
698        attachments: Vec<proto::note::NoteSyncAttachment>,
699    ) -> proto::note::NoteSyncMetadata {
700        proto::note::NoteSyncMetadata {
701            sender: Some(sender().into()),
702            note_type: note_type_to_proto(NoteType::Private),
703            tag: 7,
704            attachments,
705        }
706    }
707
708    #[test]
709    fn sync_metadata_reconstructs_metadata_with_mixed_attachments() {
710        let attachments = NoteAttachments::new(vec![
711            NoteAttachment::with_word(
712                NoteAttachmentScheme::new(42).unwrap(),
713                Word::from([1u32, 2, 3, 4]),
714            ),
715            NoteAttachment::with_words(
716                NoteAttachmentScheme::new(100).unwrap(),
717                vec![Word::from([5u32, 6, 7, 8]), Word::from([9u32, 10, 11, 12])],
718            )
719            .unwrap(),
720        ])
721        .unwrap();
722
723        let expected = NoteMetadata::new(
724            PartialNoteMetadata::new(sender(), NoteType::Private).with_tag(NoteTag::new(7)),
725            &attachments,
726        );
727
728        let reconstructed: NoteMetadata =
729            sync_metadata(sync_attachments(&attachments)).try_into().unwrap();
730
731        assert_eq!(reconstructed, expected);
732    }
733
734    #[test]
735    fn sync_metadata_reconstructs_metadata_without_attachments() {
736        let attachments = NoteAttachments::empty();
737        let expected = NoteMetadata::new(
738            PartialNoteMetadata::new(sender(), NoteType::Private).with_tag(NoteTag::new(7)),
739            &attachments,
740        );
741
742        let reconstructed: NoteMetadata = sync_metadata(Vec::new()).try_into().unwrap();
743
744        assert_eq!(reconstructed, expected);
745    }
746
747    #[test]
748    fn sync_metadata_rejects_too_many_attachments() {
749        let attachment = proto::note::NoteSyncAttachment {
750            scheme: 42,
751            payload: Some(proto::note::note_sync_attachment::Payload::Value(Word::empty().into())),
752        };
753        let attachments = vec![attachment; NoteAttachments::MAX_COUNT + 1];
754
755        let err = NoteMetadata::try_from(sync_metadata(attachments)).unwrap_err();
756
757        assert!(matches!(err, RpcConversionError::InvalidField(_)), "got {err:?}");
758    }
759
760    #[test]
761    fn sync_metadata_rejects_reserved_absent_scheme() {
762        let attachments = vec![proto::note::NoteSyncAttachment {
763            scheme: 0,
764            payload: Some(proto::note::note_sync_attachment::Payload::Value(Word::empty().into())),
765        }];
766
767        let err = NoteMetadata::try_from(sync_metadata(attachments)).unwrap_err();
768
769        assert!(matches!(err, RpcConversionError::InvalidField(_)), "got {err:?}");
770    }
771
772    #[test]
773    fn sync_metadata_rejects_missing_attachment_payload() {
774        let attachments = vec![proto::note::NoteSyncAttachment { scheme: 42, payload: None }];
775
776        let err = NoteMetadata::try_from(sync_metadata(attachments)).unwrap_err();
777
778        assert!(
779            matches!(err, RpcConversionError::MissingFieldInProtobufRepresentation { .. }),
780            "got {err:?}"
781        );
782    }
783}