Skip to main content

miden_client/rpc/domain/
note.rs

1use alloc::collections::BTreeMap;
2use alloc::vec::Vec;
3
4use miden_protocol::account::AccountId;
5use miden_protocol::block::{BlockHeader, BlockNumber};
6use miden_protocol::crypto::merkle::MerklePath;
7use miden_protocol::note::{
8    Note,
9    NoteAttachment,
10    NoteAttachmentKind,
11    NoteDetails,
12    NoteHeader,
13    NoteId,
14    NoteInclusionProof,
15    NoteMetadata,
16    NoteScript,
17    NoteTag,
18    NoteType,
19};
20use miden_protocol::{MastForest, MastNodeId, Word};
21use miden_tx::utils::serde::Deserializable;
22
23use super::{MissingFieldHelper, RpcConversionError};
24use crate::rpc::{RpcError, generated as proto};
25
26impl From<NoteId> for proto::note::NoteId {
27    fn from(value: NoteId) -> Self {
28        proto::note::NoteId { id: Some(value.into()) }
29    }
30}
31
32impl TryFrom<proto::note::NoteId> for NoteId {
33    type Error = RpcConversionError;
34
35    fn try_from(value: proto::note::NoteId) -> Result<Self, Self::Error> {
36        let word =
37            Word::try_from(value.id.ok_or(proto::note::NoteId::missing_field(stringify!(id)))?)?;
38        Ok(Self::from_raw(word))
39    }
40}
41
42impl TryFrom<proto::note::NoteMetadata> for NoteMetadata {
43    type Error = RpcConversionError;
44
45    fn try_from(value: proto::note::NoteMetadata) -> Result<Self, Self::Error> {
46        let sender = value
47            .sender
48            .ok_or_else(|| proto::note::NoteMetadata::missing_field(stringify!(sender)))?
49            .try_into()?;
50        let note_type =
51            NoteType::try_from(u64::try_from(value.note_type).expect("invalid note type"))?;
52        let tag = NoteTag::new(value.tag);
53
54        // Deserialize attachment if present
55        let attachment = if value.attachment.is_empty() {
56            NoteAttachment::default()
57        } else {
58            NoteAttachment::read_from_bytes(&value.attachment)
59                .map_err(RpcConversionError::DeserializationError)?
60        };
61
62        Ok(NoteMetadata::new(sender, note_type).with_tag(tag).with_attachment(attachment))
63    }
64}
65
66impl From<NoteMetadata> for proto::note::NoteMetadata {
67    fn from(value: NoteMetadata) -> Self {
68        use miden_tx::utils::serde::Serializable;
69        proto::note::NoteMetadata {
70            sender: Some(value.sender().into()),
71            note_type: value.note_type() as i32,
72            tag: value.tag().as_u32(),
73            attachment: value.attachment().to_bytes(),
74        }
75    }
76}
77
78impl TryFrom<proto::note::NoteHeader> for NoteHeader {
79    type Error = RpcConversionError;
80
81    fn try_from(value: proto::note::NoteHeader) -> Result<Self, Self::Error> {
82        let note_id = value
83            .note_id
84            .ok_or(proto::note::NoteHeader::missing_field(stringify!(note_id)))?
85            .try_into()?;
86        let metadata = value
87            .metadata
88            .ok_or(proto::note::NoteHeader::missing_field(stringify!(metadata)))?
89            .try_into()?;
90        Ok(NoteHeader::new(note_id, metadata))
91    }
92}
93
94impl TryFrom<proto::note::NoteInclusionInBlockProof> for NoteInclusionProof {
95    type Error = RpcConversionError;
96
97    fn try_from(value: proto::note::NoteInclusionInBlockProof) -> Result<Self, Self::Error> {
98        Ok(NoteInclusionProof::new(
99            value.block_num.into(),
100            u16::try_from(value.note_index_in_block)
101                .map_err(|_| RpcConversionError::InvalidField("NoteIndexInBlock".into()))?,
102            value
103                .inclusion_path
104                .ok_or_else(|| {
105                    proto::note::NoteInclusionInBlockProof::missing_field(stringify!(
106                        inclusion_path
107                    ))
108                })?
109                .try_into()?,
110        )?)
111    }
112}
113
114// SYNC NOTE
115// ================================================================================================
116
117/// Represents a single block's worth of note sync data from the `SyncNotesResponse`.
118#[derive(Debug, Clone)]
119pub struct NoteSyncBlock {
120    /// Block header containing the matching notes.
121    pub block_header: BlockHeader,
122    /// MMR path for verifying the block's inclusion in the MMR at `block_to`.
123    pub mmr_path: MerklePath,
124    /// Notes matching the requested tags in this block, keyed by note ID.
125    pub notes: BTreeMap<NoteId, CommittedNote>,
126}
127
128/// Represents a `SyncNotesResponse` with fields converted into domain types.
129///
130/// The response may contain multiple blocks with matching notes. When `blocks` is empty,
131/// no notes matched in the scanned range.
132#[derive(Debug)]
133pub struct NoteSyncInfo {
134    /// Number of the latest block in the chain when the response was generated.
135    pub chain_tip: BlockNumber,
136    /// The last block the node checked. Used as a cursor for pagination: if less than the
137    /// requested range end (or chain tip), the client should continue from this block.
138    pub block_to: BlockNumber,
139    /// Blocks containing matching notes, ordered by block number ascending.
140    /// May be empty if no notes matched in the range.
141    pub blocks: Vec<NoteSyncBlock>,
142}
143
144/// Result of [`NodeRpcClient::sync_notes_with_details`](crate::rpc::NodeRpcClient::sync_notes_with_details).
145///
146/// Contains fully-resolved note blocks (all metadata filled) and full note bodies for
147/// public notes. The block data and public note bodies are separated to avoid duplication:
148/// blocks carry metadata + inclusion proofs, while `public_notes` carries the note content
149/// (scripts, assets, recipient) keyed by note ID.
150pub struct SyncNotesResult {
151    /// Blocks containing matching notes with fully-resolved metadata.
152    /// After pagination is fully resolved, the last block is the range-end block
153    /// (chain tip when `block_to` is `None`), even if it contained no matching notes.
154    pub blocks: Vec<NoteSyncBlock>,
155    /// Full note bodies for public notes, keyed by note ID.
156    pub public_notes: BTreeMap<NoteId, Note>,
157}
158
159impl TryFrom<proto::rpc::SyncNotesResponse> for NoteSyncInfo {
160    type Error = RpcError;
161
162    fn try_from(value: proto::rpc::SyncNotesResponse) -> Result<Self, Self::Error> {
163        let pagination_info = value
164            .pagination_info
165            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(pagination_info)))?;
166
167        let chain_tip = BlockNumber::from(pagination_info.chain_tip);
168        let block_to = BlockNumber::from(pagination_info.block_num);
169
170        let blocks = value
171            .blocks
172            .into_iter()
173            .map(|block| {
174                let block_header = block
175                    .block_header
176                    .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(
177                        blocks.block_header
178                    )))?
179                    .try_into()?;
180
181                let mmr_path = block
182                    .mmr_path
183                    .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(
184                        blocks.mmr_path
185                    )))?
186                    .try_into()?;
187
188                let notes: BTreeMap<NoteId, CommittedNote> = block
189                    .notes
190                    .into_iter()
191                    .map(|n| {
192                        let note = CommittedNote::try_from(n)?;
193                        Ok((*note.note_id(), note))
194                    })
195                    .collect::<Result<_, RpcConversionError>>()?;
196
197                Ok(NoteSyncBlock { block_header, mmr_path, notes })
198            })
199            .collect::<Result<Vec<_>, RpcError>>()?;
200
201        Ok(NoteSyncInfo { chain_tip, block_to, blocks })
202    }
203}
204
205// COMMITTED NOTE
206// ================================================================================================
207
208/// The metadata state of a committed note.
209///
210/// The sync response provides header fields (sender, type, tag, attachment kind) but not the
211/// actual attachment data. For notes without attachments, full [`NoteMetadata`] can be
212/// constructed directly. For notes with attachments, only the header fields are available
213/// until the full metadata is fetched via `GetNotesById`.
214#[derive(Debug, Clone)]
215pub enum CommittedNoteMetadata {
216    /// Full metadata is available (no attachment, or attachment was already fetched).
217    Full(NoteMetadata),
218    /// Only the header fields are available; the attachment data has not been fetched yet.
219    // Ideally this would wrap `NoteMetadataHeader` directly, but it lacks a public
220    // constructor in the protocol crate.
221    Header {
222        sender: AccountId,
223        note_type: NoteType,
224        tag: NoteTag,
225        attachment_kind: NoteAttachmentKind,
226    },
227}
228
229impl CommittedNoteMetadata {
230    /// Returns the full metadata if available.
231    pub fn metadata(&self) -> Option<&NoteMetadata> {
232        match self {
233            Self::Full(m) => Some(m),
234            Self::Header { .. } => None,
235        }
236    }
237}
238
239/// Represents a committed note, returned as part of a `SyncNotesResponse`.
240///
241/// The sync response provides a [`NoteMetadataHeader`](crate::note::NoteMetadataHeader) but not the
242/// actual attachment data. For notes without attachments, full [`NoteMetadata`] is available
243/// immediately. For notes with attachments, the metadata starts as
244/// [`CommittedNoteMetadata::Header`] until the full data is fetched via `GetNotesById`.
245#[derive(Debug, Clone)]
246pub struct CommittedNote {
247    /// Note ID of the committed note.
248    note_id: NoteId,
249    /// Note metadata — either full or header-only depending on whether the note has an
250    /// attachment that hasn't been fetched yet.
251    metadata: CommittedNoteMetadata,
252    /// Inclusion proof for the note in the block.
253    inclusion_proof: NoteInclusionProof,
254}
255
256impl CommittedNote {
257    pub fn new(
258        note_id: NoteId,
259        metadata: CommittedNoteMetadata,
260        inclusion_proof: NoteInclusionProof,
261    ) -> Self {
262        Self { note_id, metadata, inclusion_proof }
263    }
264
265    pub fn note_id(&self) -> &NoteId {
266        &self.note_id
267    }
268
269    pub fn note_type(&self) -> NoteType {
270        match &self.metadata {
271            CommittedNoteMetadata::Full(m) => m.note_type(),
272            CommittedNoteMetadata::Header { note_type, .. } => *note_type,
273        }
274    }
275
276    pub fn tag(&self) -> NoteTag {
277        match &self.metadata {
278            CommittedNoteMetadata::Full(m) => m.tag(),
279            CommittedNoteMetadata::Header { tag, .. } => *tag,
280        }
281    }
282
283    pub fn sender(&self) -> AccountId {
284        match &self.metadata {
285            CommittedNoteMetadata::Full(m) => m.sender(),
286            CommittedNoteMetadata::Header { sender, .. } => *sender,
287        }
288    }
289
290    /// Returns the full note metadata, or `None` if only the header is available.
291    pub fn metadata(&self) -> Option<&NoteMetadata> {
292        self.metadata.metadata()
293    }
294
295    /// Returns the committed note metadata enum.
296    pub fn committed_metadata(&self) -> &CommittedNoteMetadata {
297        &self.metadata
298    }
299
300    /// Sets the full metadata, promoting from `Header` to `Full`.
301    ///
302    /// Used after fetching attachment data via `GetNotesById` for notes whose sync
303    /// response only included header fields.
304    pub fn set_metadata(&mut self, metadata: NoteMetadata) {
305        self.metadata = CommittedNoteMetadata::Full(metadata);
306    }
307
308    pub fn inclusion_proof(&self) -> &NoteInclusionProof {
309        &self.inclusion_proof
310    }
311}
312
313impl TryFrom<proto::note::NoteSyncRecord> for CommittedNote {
314    type Error = RpcConversionError;
315
316    fn try_from(note: proto::note::NoteSyncRecord) -> Result<Self, Self::Error> {
317        let proto_header = note.metadata_header.ok_or(
318            proto::rpc::SyncNotesResponse::missing_field(stringify!(notes.metadata_header)),
319        )?;
320
321        let sender = proto_header
322            .sender
323            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(
324                notes.metadata_header.sender
325            )))?
326            .try_into()?;
327        let note_type =
328            NoteType::try_from(u64::try_from(proto_header.note_type).expect("invalid note type"))?;
329        let tag = NoteTag::new(proto_header.tag);
330        let attachment_kind = u8::try_from(proto_header.attachment_kind)
331            .ok()
332            .and_then(|kind| NoteAttachmentKind::try_from(kind).ok())
333            .unwrap_or_default();
334
335        let metadata = if attachment_kind == NoteAttachmentKind::None {
336            CommittedNoteMetadata::Full(NoteMetadata::new(sender, note_type).with_tag(tag))
337        } else {
338            CommittedNoteMetadata::Header { sender, note_type, tag, attachment_kind }
339        };
340
341        let proto_inclusion_proof = note.inclusion_proof.ok_or(
342            proto::rpc::SyncNotesResponse::missing_field(stringify!(notes.inclusion_proof)),
343        )?;
344
345        let note_id: NoteId = proto_inclusion_proof
346            .note_id
347            .ok_or(proto::rpc::SyncNotesResponse::missing_field(stringify!(
348                notes.inclusion_proof.note_id
349            )))?
350            .try_into()?;
351
352        let inclusion_proof: NoteInclusionProof = proto_inclusion_proof.try_into()?;
353
354        Ok(CommittedNote::new(note_id, metadata, inclusion_proof))
355    }
356}
357
358// FETCHED NOTE
359// ================================================================================================
360
361/// Describes the possible responses from the `GetNotesById` endpoint for a single note.
362#[allow(clippy::large_enum_variant)]
363pub enum FetchedNote {
364    /// Details for a private note only include its [`NoteHeader`] and [`NoteInclusionProof`].
365    /// Other details needed to consume the note are expected to be stored locally, off-chain.
366    Private(NoteHeader, NoteInclusionProof),
367    /// Contains the full [`Note`] object alongside its [`NoteInclusionProof`].
368    Public(Note, NoteInclusionProof),
369}
370
371impl FetchedNote {
372    /// Returns the note's inclusion details.
373    pub fn inclusion_proof(&self) -> &NoteInclusionProof {
374        match self {
375            FetchedNote::Private(_, inclusion_proof) | FetchedNote::Public(_, inclusion_proof) => {
376                inclusion_proof
377            },
378        }
379    }
380
381    /// Returns the note's metadata.
382    pub fn metadata(&self) -> &NoteMetadata {
383        match self {
384            FetchedNote::Private(header, _) => header.metadata(),
385            FetchedNote::Public(note, _) => note.metadata(),
386        }
387    }
388
389    /// Returns the note's ID.
390    pub fn id(&self) -> NoteId {
391        match self {
392            FetchedNote::Private(header, _) => header.id(),
393            FetchedNote::Public(note, _) => note.id(),
394        }
395    }
396}
397
398impl TryFrom<proto::note::CommittedNote> for FetchedNote {
399    type Error = RpcConversionError;
400
401    fn try_from(value: proto::note::CommittedNote) -> Result<Self, Self::Error> {
402        let inclusion_proof = value.inclusion_proof.ok_or_else(|| {
403            proto::note::CommittedNote::missing_field(stringify!(inclusion_proof))
404        })?;
405
406        let note_id: NoteId = inclusion_proof
407            .note_id
408            .ok_or_else(|| {
409                proto::note::CommittedNote::missing_field(stringify!(inclusion_proof.note_id))
410            })?
411            .try_into()?;
412
413        let inclusion_proof = NoteInclusionProof::try_from(inclusion_proof)?;
414
415        let note = value
416            .note
417            .ok_or_else(|| proto::note::CommittedNote::missing_field(stringify!(note)))?;
418
419        let metadata = note
420            .metadata
421            .ok_or_else(|| proto::note::CommittedNote::missing_field(stringify!(note.metadata)))?
422            .try_into()?;
423
424        if let Some(detail_bytes) = note.details {
425            let details = NoteDetails::read_from_bytes(&detail_bytes)?;
426            let (assets, recipient) = details.into_parts();
427
428            Ok(FetchedNote::Public(Note::new(assets, metadata, recipient), inclusion_proof))
429        } else {
430            let note_header = NoteHeader::new(note_id, metadata);
431            Ok(FetchedNote::Private(note_header, inclusion_proof))
432        }
433    }
434}
435
436// NOTE SCRIPT
437// ================================================================================================
438
439impl TryFrom<proto::note::NoteScript> for NoteScript {
440    type Error = RpcConversionError;
441
442    fn try_from(note_script: proto::note::NoteScript) -> Result<Self, Self::Error> {
443        let mast_forest = MastForest::read_from_bytes(&note_script.mast)?;
444        let entrypoint = MastNodeId::from_u32_safe(note_script.entrypoint, &mast_forest)?;
445        Ok(NoteScript::from_parts(alloc::sync::Arc::new(mast_forest), entrypoint))
446    }
447}