Skip to main content

miden_protocol/note/
metadata.rs

1use super::{
2    AccountId,
3    ByteReader,
4    ByteWriter,
5    Deserializable,
6    DeserializationError,
7    Felt,
8    NoteTag,
9    NoteType,
10    Serializable,
11    Word,
12};
13use crate::Hasher;
14use crate::note::{NoteAttachmentHeader, NoteAttachments};
15
16// PARTIAL NOTE METADATA
17// ================================================================================================
18
19/// The user-facing metadata associated with a note.
20///
21/// Contains the sender, note type, and tag. For the full protocol-level encoding (including
22/// attachment headers and commitment computation), see [`NoteMetadata`].
23#[derive(Debug, Clone, Copy, Eq, PartialEq)]
24pub struct PartialNoteMetadata {
25    /// The ID of the account which created the note.
26    sender: AccountId,
27
28    /// Defines how the note is to be stored (e.g. public or private).
29    note_type: NoteType,
30
31    /// A value which can be used by the recipient(s) to identify notes intended for them.
32    tag: NoteTag,
33}
34
35impl PartialNoteMetadata {
36    // CONSTRUCTORS
37    // --------------------------------------------------------------------------------------------
38
39    /// Returns a new [`PartialNoteMetadata`] instantiated with the specified parameters.
40    ///
41    /// The tag defaults to [`NoteTag::default()`]. Use [`PartialNoteMetadata::with_tag`] to set a
42    /// specific tag if needed.
43    pub fn new(sender: AccountId, note_type: NoteType) -> Self {
44        Self {
45            sender,
46            note_type,
47            tag: NoteTag::default(),
48        }
49    }
50
51    // ACCESSORS
52    // --------------------------------------------------------------------------------------------
53
54    /// Returns the account which created the note.
55    pub fn sender(&self) -> AccountId {
56        self.sender
57    }
58
59    /// Returns the note's type.
60    pub fn note_type(&self) -> NoteType {
61        self.note_type
62    }
63
64    /// Returns the tag associated with the note.
65    pub fn tag(&self) -> NoteTag {
66        self.tag
67    }
68
69    /// Returns `true` if the note is private, `false` otherwise.
70    pub fn is_private(&self) -> bool {
71        self.note_type == NoteType::Private
72    }
73
74    /// Returns `true` if the note is public, `false` otherwise.
75    pub fn is_public(&self) -> bool {
76        self.note_type == NoteType::Public
77    }
78
79    // MUTATORS
80    // --------------------------------------------------------------------------------------------
81
82    /// Mutates the note's tag by setting it to the provided value.
83    pub fn set_tag(&mut self, tag: NoteTag) {
84        self.tag = tag;
85    }
86
87    /// Returns a new [`PartialNoteMetadata`] with the tag set to the provided value.
88    ///
89    /// This is a builder method that consumes self and returns a new instance for method chaining.
90    pub fn with_tag(mut self, tag: NoteTag) -> Self {
91        self.tag = tag;
92        self
93    }
94}
95
96// SERIALIZATION
97// ================================================================================================
98
99impl Serializable for PartialNoteMetadata {
100    fn write_into<W: ByteWriter>(&self, target: &mut W) {
101        NoteMetadata::VERSION_1.write_into(target);
102        self.note_type().write_into(target);
103        self.sender().write_into(target);
104        self.tag().write_into(target);
105    }
106
107    fn get_size_hint(&self) -> usize {
108        NoteMetadata::VERSION_1.get_size_hint()
109            + self.note_type().get_size_hint()
110            + self.sender().get_size_hint()
111            + self.tag().get_size_hint()
112    }
113}
114
115impl Deserializable for PartialNoteMetadata {
116    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
117        let version = u8::read_from(source)?;
118
119        if version != NoteMetadata::VERSION_1 {
120            return Err(DeserializationError::InvalidValue(format!(
121                "note version is {} but only version {} is supported",
122                version,
123                NoteMetadata::VERSION_1,
124            )));
125        }
126
127        let note_type = NoteType::read_from(source)?;
128        let sender = AccountId::read_from(source)?;
129        let tag = NoteTag::read_from(source)?;
130
131        Ok(PartialNoteMetadata::new(sender, note_type).with_tag(tag))
132    }
133}
134
135// NOTE METADATA
136// ================================================================================================
137
138/// Protocol-level note metadata that combines [`PartialNoteMetadata`] with attachment information.
139///
140/// This type wraps `PartialNoteMetadata` together with attachment headers and an attachment
141/// commitment, and knows how to encode them into a [`Word`] and compute commitments.
142///
143/// The metadata word is encoded as a single [`Word`] (4 felts) with the following layout:
144///
145/// ```text
146/// 0th felt: [sender_id_suffix (56 bits) | reserved (1 bit) | note_type (1 bit) | version (6 bits)]
147/// 1st felt: [sender_id_prefix (64 bits)]
148/// 2nd felt: [reserved (32 bits) | note_tag (32 bits)]
149/// 3rd felt: [attachment_3_scheme (16 bits) | attachment_2_scheme (16 bits) |
150///            attachment_1_scheme (16 bits) | attachment_0_scheme (16 bits)]
151/// ```
152///
153/// Felt validity is guaranteed:
154/// - 0th felt: The lower 8 bits of the account ID suffix are `0` by construction, so they can be
155///   overwritten. The suffix's MSB is zero so the felt stays valid when lower bits are set.
156/// - 1st felt: Equivalent to the account ID prefix, so it inherits its validity.
157/// - 2nd felt: The tag is a u32 and the reserved bits are _currently_ set to zero, however users
158///   shouldn't assume these are zero.
159/// - 3rd felt: Max value is `0xFFFEFFFE_FFFEFFFE` (schemes capped at 65534), which is less than
160///   `p`.
161///
162/// The version is hardcoded to 1 and is reserved for forward compatibility.
163#[derive(Debug, Clone, Copy, Eq, PartialEq)]
164pub struct NoteMetadata {
165    partial_metadata: PartialNoteMetadata,
166    attachment_headers: [NoteAttachmentHeader; NoteAttachments::MAX_COUNT],
167    attachments_commitment: Word,
168}
169
170impl NoteMetadata {
171    // CONSTANTS
172    // --------------------------------------------------------------------------------------------
173
174    /// The number of bits by which the note type is offset in the first felt of the metadata word.
175    const NOTE_TYPE_SHIFT: u64 = 6;
176
177    /// Version 1 of the note metadata encoding.
178    ///
179    /// It is encoded using 6 bits.
180    const VERSION_1: u8 = 1;
181
182    // CONSTRUCTORS
183    // --------------------------------------------------------------------------------------------
184
185    /// Returns a new [`NoteMetadata`] derived from the given partial metadata and attachments.
186    ///
187    /// The attachment headers and commitment are derived from the provided attachments.
188    pub fn new(partial_metadata: PartialNoteMetadata, attachments: &NoteAttachments) -> Self {
189        Self::from_parts(partial_metadata, attachments.to_headers(), attachments.to_commitment())
190    }
191
192    /// Creates a [`NoteMetadata`] from its raw parts.
193    ///
194    /// Prefer [`Self::new`] whenever possible.
195    pub fn from_parts(
196        partial_metadata: PartialNoteMetadata,
197        attachment_headers: [NoteAttachmentHeader; NoteAttachments::MAX_COUNT],
198        attachments_commitment: Word,
199    ) -> Self {
200        Self {
201            partial_metadata,
202            attachment_headers,
203            attachments_commitment,
204        }
205    }
206
207    // ACCESSORS
208    // --------------------------------------------------------------------------------------------
209
210    /// Returns the inner [`PartialNoteMetadata`].
211    pub fn partial_metadata(&self) -> &PartialNoteMetadata {
212        &self.partial_metadata
213    }
214
215    /// Returns the account which created the note.
216    pub fn sender(&self) -> AccountId {
217        self.partial_metadata.sender()
218    }
219
220    /// Returns the note's type.
221    pub fn note_type(&self) -> NoteType {
222        self.partial_metadata.note_type()
223    }
224
225    /// Returns the tag associated with the note.
226    pub fn tag(&self) -> NoteTag {
227        self.partial_metadata.tag()
228    }
229
230    /// Returns the attachment headers.
231    pub fn attachment_headers(&self) -> &[NoteAttachmentHeader; NoteAttachments::MAX_COUNT] {
232        &self.attachment_headers
233    }
234
235    /// Returns the attachments commitment.
236    pub fn attachments_commitment(&self) -> Word {
237        self.attachments_commitment
238    }
239
240    /// Returns `true` if the metadata advertises at least one attachment.
241    ///
242    /// The metadata carries only attachment scheme markers, not their content, so this does not
243    /// imply the content is available locally.
244    pub fn has_attachments(&self) -> bool {
245        self.attachment_headers.iter().any(|header| !header.is_absent())
246    }
247
248    /// Returns `true` if the note is private, `false` otherwise.
249    pub fn is_private(&self) -> bool {
250        self.partial_metadata.is_private()
251    }
252
253    /// Returns `true` if the note is public, `false` otherwise.
254    pub fn is_public(&self) -> bool {
255        self.partial_metadata.is_public()
256    }
257
258    /// Returns the metadata encoded as a [`Word`].
259    ///
260    /// See [`NoteMetadata`] docs for the layout.
261    pub fn to_metadata_word(&self) -> Word {
262        let mut word = Word::empty();
263        word[0] = merge_sender_suffix_and_note_type(
264            self.partial_metadata.sender.suffix(),
265            self.partial_metadata.note_type,
266        );
267        word[1] = self.partial_metadata.sender.prefix().as_felt();
268        word[2] = self.partial_metadata.tag.into();
269        word[3] = merge_schemes(self.attachment_headers);
270        word
271    }
272
273    /// Returns the commitment to the note metadata, which is defined as:
274    ///
275    /// ```text
276    /// hash(NOTE_METADATA_WORD || ATTACHMENTS_COMMITMENT)
277    /// ```
278    pub fn to_commitment(&self) -> Word {
279        Hasher::merge(&[self.to_metadata_word(), self.attachments_commitment])
280    }
281
282    /// Consumes self and returns the inner [`PartialNoteMetadata`].
283    pub fn into_partial_metadata(self) -> PartialNoteMetadata {
284        self.partial_metadata
285    }
286}
287
288impl Serializable for NoteMetadata {
289    fn write_into<W: ByteWriter>(&self, target: &mut W) {
290        self.partial_metadata.write_into(target);
291
292        let present_headers_iter =
293            self.attachment_headers.iter().filter(|header| !header.is_absent());
294
295        let num_headers_present = u8::try_from(present_headers_iter.clone().count())
296            .expect("num attachments is validated to be at most 4");
297        num_headers_present.write_into(target);
298        target.write_many(present_headers_iter);
299
300        self.attachments_commitment.write_into(target);
301    }
302
303    fn get_size_hint(&self) -> usize {
304        self.partial_metadata.get_size_hint()
305            + core::mem::size_of::<u8>()
306            + self
307                .attachment_headers
308                .iter()
309                .filter(|header| !header.is_absent())
310                .map(NoteAttachmentHeader::get_size_hint)
311                .sum::<usize>()
312            + self.attachments_commitment.get_size_hint()
313    }
314}
315
316impl Deserializable for NoteMetadata {
317    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
318        let partial_metadata = PartialNoteMetadata::read_from(source)?;
319
320        let num_headers_present = u8::read_from(source)? as usize;
321        if num_headers_present > NoteAttachments::MAX_COUNT {
322            return Err(DeserializationError::InvalidValue(format!(
323                "number of attachment headers ({num_headers_present}) exceeds maximum ({})",
324                NoteAttachments::MAX_COUNT
325            )));
326        }
327
328        let mut attachment_headers = [NoteAttachmentHeader::absent(); NoteAttachments::MAX_COUNT];
329        for header in attachment_headers.iter_mut().take(num_headers_present) {
330            *header = NoteAttachmentHeader::read_from(source)?;
331        }
332
333        let attachment_commitment = Word::read_from(source)?;
334
335        Ok(Self::from_parts(partial_metadata, attachment_headers, attachment_commitment))
336    }
337}
338
339// HELPER FUNCTIONS
340// ================================================================================================
341
342/// Merges the suffix of an [`AccountId`] and note metadata into a single [`Felt`].
343///
344/// The layout is as follows:
345///
346/// ```text
347/// [sender_id_suffix (56 bits) | reserved (1 bit) | note_type (1 bit) | version (6 bits)]
348/// ```
349///
350/// The most significant bit of the suffix is guaranteed to be zero, so the felt retains its
351/// validity.
352///
353/// The `sender_id_suffix` is the suffix of the sender's account ID.
354fn merge_sender_suffix_and_note_type(sender_id_suffix: Felt, note_type: NoteType) -> Felt {
355    let mut merged = sender_id_suffix.as_canonical_u64();
356
357    let note_type_byte = note_type as u8;
358    debug_assert!(note_type_byte < 2, "note type must not contain values >= 2");
359    // note_type at bit 6, version at bits 0..=5 (hardcoded to NoteMetadata::VERSION_1)
360    merged |= (note_type_byte as u64) << NoteMetadata::NOTE_TYPE_SHIFT;
361    merged |= NoteMetadata::VERSION_1 as u64;
362
363    // SAFETY: The most significant bit of the suffix is zero by construction so the u64 will be a
364    // valid felt.
365    Felt::try_from(merged).expect("encoded value should be a valid felt")
366}
367
368/// Merges four attachment schemes into a single [`Felt`].
369///
370/// The layout is as follows:
371///
372/// ```text
373/// [attachment_3_scheme (16 bits) | attachment_2_scheme (16 bits) |
374///  attachment_1_scheme (16 bits) | attachment_0_scheme (16 bits)]
375/// ```
376///
377/// Max value: `0xFFFEFFFE_FFFEFFFE` < p. Schemes are capped at 65534.
378fn merge_schemes(headers: [NoteAttachmentHeader; NoteAttachments::MAX_COUNT]) -> Felt {
379    let mut merged: u64 = headers[0].as_u16() as u64;
380    merged |= (headers[1].as_u16() as u64) << 16;
381    merged |= (headers[2].as_u16() as u64) << 32;
382    merged |= (headers[3].as_u16() as u64) << 48;
383
384    Felt::try_from(merged).expect("encoded value should be a valid felt (schemes <= 65534)")
385}
386
387// TESTS
388// ================================================================================================
389
390#[cfg(test)]
391mod tests {
392
393    use assert_matches::assert_matches;
394
395    use super::*;
396    use crate::note::{NoteAttachment, NoteAttachmentScheme};
397    use crate::testing::account_id::ACCOUNT_ID_MAX_ONES;
398
399    #[test]
400    fn note_metadata_word_encodes_attachment_header() -> anyhow::Result<()> {
401        let sender = AccountId::try_from(ACCOUNT_ID_MAX_ONES).unwrap();
402        let partial_metadata =
403            PartialNoteMetadata::new(sender, NoteType::Public).with_tag(NoteTag::new(0xff));
404        let attachment0 = NoteAttachment::with_word(
405            NoteAttachmentScheme::new(1)?,
406            Word::from([10, 20, 30, 40u32]),
407        );
408        let attachment1 = NoteAttachment::with_words(
409            NoteAttachmentScheme::new(0xfffe)?,
410            vec![Word::from([10, 20, 30, 40u32]), Word::from([10, 20, 30, 40u32])],
411        )?;
412        let attachments = NoteAttachments::new(vec![attachment0, attachment1])?;
413        let metadata = NoteMetadata::new(partial_metadata, &attachments);
414
415        let encoded = metadata.to_metadata_word();
416
417        let tag = encoded[2].as_canonical_u64();
418        assert_eq!(tag, 0x0000_0000_0000_00ff);
419
420        let schemes = encoded[3].as_canonical_u64();
421        // scheme 3 and 4 are 0, 2 is 0xfffe, 1 is 0x1
422        assert_eq!(schemes, 0x0000_0000_fffe_0001);
423
424        Ok(())
425    }
426
427    #[rstest::rstest]
428    #[case::attachment_none([])]
429    #[case::attachment_two_words([
430      NoteAttachment::with_word(NoteAttachmentScheme::none(), Word::from([3, 4, 5, 6u32])),
431      NoteAttachment::with_word(NoteAttachmentScheme::none(), Word::from([3, 4, 5, 6u32])),
432    ])]
433    #[case::attachment_word_and_two_arrays([
434      NoteAttachment::with_word(NoteAttachmentScheme::none(), Word::from([3, 4, 5, 6u32])),
435      NoteAttachment::with_words(
436        NoteAttachmentScheme::MAX,
437        vec![Word::from([5, 5, 5, 5u32]); 2],
438      )?,
439      NoteAttachment::with_words(
440        NoteAttachmentScheme::MAX,
441        vec![Word::from([10, 10, 10, 10u32]); NoteAttachment::MAX_NUM_WORDS as usize],
442      )?,
443    ])]
444    #[test]
445    fn note_metadata_serde(
446        #[case] attachments: impl IntoIterator<Item = NoteAttachment>,
447    ) -> anyhow::Result<()> {
448        // Use the Account ID with the maximum one bits to test if the merge function always
449        // produces valid felts.
450        let sender = AccountId::try_from(ACCOUNT_ID_MAX_ONES).unwrap();
451        let note_type = NoteType::Public;
452        let tag = NoteTag::new(u32::MAX);
453        let partial_metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag);
454        let attachments = NoteAttachments::new(attachments.into_iter().collect())?;
455        let metadata = NoteMetadata::new(partial_metadata, &attachments);
456
457        // Partial Metadata Roundtrip
458        let deserialized = PartialNoteMetadata::read_from_bytes(&partial_metadata.to_bytes())?;
459        assert_eq!(deserialized, partial_metadata);
460
461        // Metadata Roundtrip
462        let roundtripped = NoteMetadata::read_from_bytes(&metadata.to_bytes())?;
463        assert_eq!(roundtripped, metadata);
464
465        Ok(())
466    }
467
468    /// Pins the note metadata layout.
469    #[rstest::rstest]
470    #[case::private(NoteType::Private, 0b0000_0001)]
471    #[case::public(NoteType::Public, 0b0100_0001)]
472    #[test]
473    fn note_metadata_first_felt_layout(
474        #[case] note_type: NoteType,
475        #[case] expected_low_byte: u64,
476    ) -> anyhow::Result<()> {
477        const SUFFIX_MASK: u64 = !0xff;
478
479        // Use the Account ID with the maximum one bits to check that the suffix is untouched even
480        // when all of its non-constrained bits are set.
481        let sender = AccountId::try_from(ACCOUNT_ID_MAX_ONES)?;
482        let partial_metadata = PartialNoteMetadata::new(sender, note_type);
483        let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::default());
484
485        let first_felt = metadata.to_metadata_word()[0].as_canonical_u64();
486
487        assert_eq!(first_felt & !SUFFIX_MASK, expected_low_byte);
488        assert_eq!(first_felt & SUFFIX_MASK, sender.suffix().as_canonical_u64());
489        assert_eq!(u64::from(NoteMetadata::VERSION_1), 1);
490
491        Ok(())
492    }
493
494    #[test]
495    fn partial_note_metadata_deserialization_rejects_unsupported_version() {
496        let error = PartialNoteMetadata::read_from_bytes(&[0]).unwrap_err();
497
498        assert_matches!(error, DeserializationError::InvalidValue(message) => {
499            assert!(message.contains("note version is 0"));
500        });
501    }
502}