Skip to main content

miden_standards/note/
p2id.rs

1use alloc::vec::Vec;
2
3use miden_protocol::account::AccountId;
4use miden_protocol::assembly::Path;
5use miden_protocol::asset::Asset;
6use miden_protocol::crypto::rand::FeltRng;
7use miden_protocol::errors::NoteError;
8use miden_protocol::note::{
9    Note,
10    NoteAssets,
11    NoteAttachment,
12    NoteAttachments,
13    NoteRecipient,
14    NoteScript,
15    NoteScriptRoot,
16    NoteStorage,
17    NoteTag,
18    NoteType,
19    PartialNoteMetadata,
20};
21use miden_protocol::utils::sync::LazyLock;
22use miden_protocol::{Felt, Word};
23
24use crate::StandardsLib;
25use crate::note::costs::{NoteConsumptionCost, P2ID_CONSUMPTION_CYCLES};
26// NOTE SCRIPT
27// ================================================================================================
28
29/// Path to the P2ID note script procedure in the standards library.
30const P2ID_SCRIPT_PATH: &str = "::miden::standards::notes::p2id::main";
31
32// Initialize the P2ID note script only once
33static P2ID_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
34    let standards_lib = StandardsLib::default();
35    let path = Path::new(P2ID_SCRIPT_PATH);
36    NoteScript::from_package_reference(standards_lib.as_ref(), path)
37        .expect("Standards library contains P2ID note script procedure")
38});
39
40// P2ID NOTE
41// ================================================================================================
42
43/// A Pay-to-ID (P2ID) note: transfers `assets` from `sender` to the `target` account.
44///
45/// Only the `target` account can consume the note and claim its assets.
46///
47/// Construct one with the [builder](P2idNote::builder), which sets sensible defaults for the
48/// optional parameters (private note type, zero salt, no attachments) and requires at least one
49/// asset. Convert a `P2idNote` into a protocol [`Note`] infallibly via `Note::from`.
50#[derive(Debug, Clone)]
51pub struct P2idNote {
52    sender: AccountId,
53    storage: P2idNoteStorage,
54    serial_number: Word,
55    note_type: NoteType,
56    assets: NoteAssets,
57    attachments: NoteAttachments,
58}
59
60#[bon::bon]
61impl P2idNote {
62    /// Builds a new [`P2idNote`].
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if:
67    /// - No assets were provided.
68    /// - The assets or attachments exceed their protocol limits (see [`NoteAssets::new`] and
69    ///   [`NoteAttachments::new`]).
70    #[builder]
71    pub fn new(
72        #[builder(field)] assets: Vec<Asset>,
73        #[builder(field)] attachments: Vec<NoteAttachment>,
74        sender: AccountId,
75        target: AccountId,
76        serial_number: Word,
77        #[builder(default)] note_type: NoteType,
78        #[builder(default)] salt: [Felt; 2],
79    ) -> Result<Self, NoteError> {
80        if assets.is_empty() {
81            return Err(NoteError::other("a P2ID note must contain at least one asset"));
82        }
83
84        let storage = P2idNoteStorage::new(target).with_salt(salt);
85        let assets = NoteAssets::new(assets)?;
86        let attachments = NoteAttachments::new(attachments)?;
87
88        Ok(Self {
89            sender,
90            storage,
91            serial_number,
92            note_type,
93            assets,
94            attachments,
95        })
96    }
97}
98
99impl P2idNote {
100    // CONSTANTS
101    // --------------------------------------------------------------------------------------------
102
103    /// Expected number of storage items of the P2ID note.
104    pub const NUM_STORAGE_ITEMS: usize = P2idNoteStorage::NUM_ITEMS;
105
106    // PUBLIC ACCESSORS
107    // --------------------------------------------------------------------------------------------
108
109    /// Returns the script of the P2ID (Pay-to-ID) note.
110    pub fn script() -> NoteScript {
111        P2ID_SCRIPT.clone()
112    }
113
114    /// Returns the P2ID (Pay-to-ID) note script root.
115    pub fn script_root() -> NoteScriptRoot {
116        P2ID_SCRIPT.root()
117    }
118
119    /// Returns the account ID of the note's sender.
120    pub fn sender(&self) -> AccountId {
121        self.sender
122    }
123
124    /// Returns the note's storage.
125    pub fn storage(&self) -> P2idNoteStorage {
126        self.storage
127    }
128
129    /// Returns the account ID of the note's target (the only account that can consume it).
130    pub fn target(&self) -> AccountId {
131        self.storage.target()
132    }
133
134    /// Returns the note's serial number.
135    pub fn serial_number(&self) -> Word {
136        self.serial_number
137    }
138
139    /// Returns the note's type.
140    pub fn note_type(&self) -> NoteType {
141        self.note_type
142    }
143
144    /// Returns the assets carried by the note.
145    pub fn assets(&self) -> &NoteAssets {
146        &self.assets
147    }
148
149    /// Returns the attachments carried by the note.
150    pub fn attachments(&self) -> &NoteAttachments {
151        &self.attachments
152    }
153}
154
155// BUILDER EXTENSIONS
156// ================================================================================================
157
158impl<S: p2id_note_builder::State> P2idNoteBuilder<S> {
159    /// Adds a single asset to the note. At least one asset is required for `.build()` to succeed.
160    pub fn asset(mut self, asset: impl Into<Asset>) -> Self {
161        self.assets.push(asset.into());
162        self
163    }
164
165    /// Adds multiple assets to the note.
166    pub fn assets(mut self, assets: impl IntoIterator<Item = impl Into<Asset>>) -> Self {
167        self.assets.extend(assets.into_iter().map(Into::into));
168        self
169    }
170
171    /// Adds a single attachment to the note.
172    pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
173        self.attachments.push(attachment.into());
174        self
175    }
176
177    /// Adds multiple attachments to the note.
178    pub fn attachments(
179        mut self,
180        attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
181    ) -> Self {
182        self.attachments.extend(attachments.into_iter().map(Into::into));
183        self
184    }
185}
186
187impl<S: p2id_note_builder::State> P2idNoteBuilder<S>
188where
189    S::SerialNumber: p2id_note_builder::IsUnset,
190{
191    /// Draws a serial number from `rng` and sets it on the builder.
192    pub fn generate_serial_number(
193        self,
194        rng: &mut impl FeltRng,
195    ) -> P2idNoteBuilder<p2id_note_builder::SetSerialNumber<S>> {
196        self.serial_number(rng.draw_word())
197    }
198}
199
200// CONVERSIONS
201// ================================================================================================
202
203impl From<P2idNote> for Note {
204    fn from(note: P2idNote) -> Self {
205        let recipient = note.storage.into_recipient(note.serial_number);
206        let tag = NoteTag::with_account_target(note.storage.target());
207        let metadata = PartialNoteMetadata::new(note.sender, note.note_type).with_tag(tag);
208
209        Note::with_attachments(note.assets, metadata, recipient, note.attachments)
210    }
211}
212
213// P2ID NOTE STORAGE
214// ================================================================================================
215
216/// Canonical storage representation for a P2ID note.
217///
218/// Contains the identifier of the target account that is authorized
219/// to consume the note. Only the account matching this ID can execute
220/// the note and claim its assets.
221///
222/// The salt is included in the storage commitment. A random salt kept private prevents the target
223/// account ID from being determined by comparing commitments for candidate account IDs.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub struct P2idNoteStorage {
226    target: AccountId,
227    salt: [Felt; 2],
228}
229
230impl P2idNoteStorage {
231    // CONSTANTS
232    // --------------------------------------------------------------------------------------------
233
234    /// Expected number of storage items of the P2ID note.
235    pub const NUM_ITEMS: usize = 4;
236
237    /// Creates P2ID note storage targeting the given account with a zero salt.
238    pub fn new(target: AccountId) -> Self {
239        Self { target, salt: [Felt::ZERO; 2] }
240    }
241
242    /// Sets the salt included in the storage commitment.
243    ///
244    /// # Privacy
245    /// For privacy, sample both elements uniformly at random and keep them secret. The default zero
246    /// salt does not prevent target-account enumeration. Salt does not hide account-derived note
247    /// tags.
248    pub fn with_salt(mut self, salt: [Felt; 2]) -> Self {
249        self.salt = salt;
250        self
251    }
252
253    /// Consumes the storage and returns a P2ID [`NoteRecipient`] with the provided serial number.
254    ///
255    /// Notes created with this recipient will be P2ID notes consumable by the specified target
256    /// account stored in this [`P2idNoteStorage`].
257    pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
258        NoteRecipient::new(serial_num, P2idNote::script(), NoteStorage::from(self))
259    }
260
261    /// Returns the target account ID.
262    pub fn target(&self) -> AccountId {
263        self.target
264    }
265
266    /// Returns the salt included in the storage commitment.
267    pub fn salt(&self) -> [Felt; 2] {
268        self.salt
269    }
270}
271
272impl From<P2idNoteStorage> for NoteStorage {
273    fn from(storage: P2idNoteStorage) -> Self {
274        // Storage layout:
275        // [ account_id_suffix, account_id_prefix, salt_0, salt_1 ]
276        NoteStorage::new(vec![
277            storage.target.suffix(),
278            storage.target.prefix().as_felt(),
279            storage.salt[0],
280            storage.salt[1],
281        ])
282        .expect("number of storage items should not exceed max storage items")
283    }
284}
285
286impl TryFrom<&[Felt]> for P2idNoteStorage {
287    type Error = NoteError;
288
289    fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
290        if note_storage.len() != P2idNote::NUM_STORAGE_ITEMS {
291            return Err(NoteError::InvalidNoteStorageLength {
292                expected: P2idNote::NUM_STORAGE_ITEMS,
293                actual: note_storage.len(),
294            });
295        }
296
297        let target = AccountId::try_from_elements(note_storage[0], note_storage[1])
298            .map_err(|err| NoteError::other_with_source("failed to create account id", err))?;
299
300        Ok(Self {
301            target,
302            salt: [note_storage[2], note_storage[3]],
303        })
304    }
305}
306
307// NOTE CONSUMPTION COST
308// ================================================================================================
309
310impl NoteConsumptionCost for P2idNote {
311    fn consumption_cycles() -> u32 {
312        P2ID_CONSUMPTION_CYCLES
313    }
314}
315
316// TESTS
317// ================================================================================================
318
319#[cfg(test)]
320mod tests {
321    use assert_matches::assert_matches;
322    use miden_protocol::account::{AccountId, AccountType};
323    use miden_protocol::asset::FungibleAsset;
324    use miden_protocol::crypto::rand::RandomCoin;
325    use miden_protocol::errors::NoteError;
326    use miden_protocol::{Felt, Word};
327
328    use super::*;
329
330    // STORAGE TESTS
331    // --------------------------------------------------------------------------------------------
332
333    #[test]
334    fn try_from_valid_storage_succeeds() {
335        let target = AccountId::builder()
336            .account_type(AccountType::Private)
337            .build_with_seed([1u8; 32]);
338
339        let salt = [Felt::ONE, Felt::from(2u32)];
340        let storage = vec![target.suffix(), target.prefix().as_felt(), salt[0], salt[1]];
341
342        let parsed =
343            P2idNoteStorage::try_from(storage.as_slice()).expect("storage should be valid");
344
345        assert_eq!(parsed.target(), target);
346        assert_eq!(parsed.salt(), salt);
347        assert_eq!(NoteStorage::from(parsed).items(), storage.as_slice());
348    }
349
350    #[test]
351    fn try_from_invalid_length_returns_error() {
352        for len in [0, 1, 2, 3, 5] {
353            let storage = vec![Felt::ZERO; len];
354            let err = P2idNoteStorage::try_from(storage.as_slice())
355                .expect_err("should fail due to invalid length");
356
357            assert_matches!(err, NoteError::InvalidNoteStorageLength {
358                expected: P2idNote::NUM_STORAGE_ITEMS,
359                actual,
360            } => assert_eq!(actual, len));
361        }
362    }
363
364    #[test]
365    fn try_from_invalid_storage_contents_returns_error() {
366        let storage = vec![
367            Felt::new_unchecked(999_u64),
368            Felt::new_unchecked(888_u64),
369            Felt::ZERO,
370            Felt::ZERO,
371        ];
372
373        let err = P2idNoteStorage::try_from(storage.as_slice())
374            .expect_err("should fail due to invalid account id encoding");
375
376        assert!(matches!(err, NoteError::Other { source: Some(_), .. }));
377    }
378
379    // BUILDER TESTS
380    // --------------------------------------------------------------------------------------------
381
382    fn sender() -> AccountId {
383        AccountId::builder()
384            .account_type(AccountType::Private)
385            .build_with_seed([1u8; 32])
386    }
387
388    fn target() -> AccountId {
389        AccountId::builder()
390            .account_type(AccountType::Private)
391            .build_with_seed([2u8; 32])
392    }
393
394    fn faucet_a() -> AccountId {
395        AccountId::builder()
396            .account_type(AccountType::Public)
397            .build_with_seed([3u8; 32])
398    }
399
400    fn faucet_b() -> AccountId {
401        AccountId::builder()
402            .account_type(AccountType::Public)
403            .build_with_seed([4u8; 32])
404    }
405
406    /// The minimal builder uses defaults for everything but the required fields.
407    #[test]
408    fn builder_minimal_uses_defaults() {
409        let note = P2idNote::builder()
410            .sender(sender())
411            .target(target())
412            .serial_number(Word::empty())
413            .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
414            .build()
415            .unwrap();
416
417        assert_eq!(note.sender(), sender());
418        assert_eq!(note.target(), target());
419        assert_eq!(note.storage().salt(), [Felt::ZERO; 2]);
420        assert_eq!(note.note_type(), NoteType::default());
421        assert_eq!(note.assets().num_assets(), 1);
422        assert_eq!(note.attachments().num_attachments(), 0);
423    }
424
425    #[test]
426    fn salt_changes_storage_and_recipient_commitments() {
427        let storage = P2idNoteStorage::new(target());
428        let recipient = storage.into_recipient(Word::empty());
429
430        for salt in [[Felt::ONE, Felt::ZERO], [Felt::ZERO, Felt::ONE]] {
431            let note: Note = P2idNote::builder()
432                .sender(sender())
433                .target(target())
434                .salt(salt)
435                .serial_number(Word::empty())
436                .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
437                .build()
438                .unwrap()
439                .into();
440
441            assert_eq!(note.recipient(), &storage.with_salt(salt).into_recipient(Word::empty()));
442            assert_ne!(note.recipient().storage().commitment(), recipient.storage().commitment());
443            assert_ne!(note.recipient().digest(), recipient.digest());
444        }
445    }
446
447    /// `.asset()` and `.assets()` both append, so they can be combined and called repeatedly.
448    #[test]
449    fn builder_accumulates_assets() {
450        let mut rng = RandomCoin::new(Word::empty());
451        let note = P2idNote::builder()
452            .sender(sender())
453            .target(target())
454            .asset(FungibleAsset::new(faucet_a(), 100).unwrap())
455            .assets([Asset::from(FungibleAsset::new(faucet_b(), 200).unwrap())])
456            .generate_serial_number(&mut rng)
457            .build()
458            .unwrap();
459
460        assert_eq!(note.assets().num_assets(), 2);
461        assert_ne!(note.serial_number(), Word::empty());
462    }
463
464    /// A P2ID note must carry at least one asset.
465    #[test]
466    fn builder_rejects_empty_assets() {
467        let err = P2idNote::builder()
468            .sender(sender())
469            .target(target())
470            .serial_number(Word::empty())
471            .build()
472            .expect_err("a note without assets must be rejected");
473
474        assert_matches!(err, NoteError::Other { error_msg, .. } => {
475            assert!(error_msg.contains("note must contain at least one asset"))
476        });
477    }
478}