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, no attachments) and requires at least one asset.
49/// 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        #[builder(name = target, with = |target: AccountId| P2idNoteStorage::new(target))]
76        storage: P2idNoteStorage,
77        serial_number: Word,
78        #[builder(default)] note_type: NoteType,
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 assets = NoteAssets::new(assets)?;
85        let attachments = NoteAttachments::new(attachments)?;
86
87        Ok(Self {
88            sender,
89            storage,
90            serial_number,
91            note_type,
92            assets,
93            attachments,
94        })
95    }
96}
97
98impl P2idNote {
99    // CONSTANTS
100    // --------------------------------------------------------------------------------------------
101
102    /// Expected number of storage items of the P2ID note.
103    pub const NUM_STORAGE_ITEMS: usize = P2idNoteStorage::NUM_ITEMS;
104
105    // PUBLIC ACCESSORS
106    // --------------------------------------------------------------------------------------------
107
108    /// Returns the script of the P2ID (Pay-to-ID) note.
109    pub fn script() -> NoteScript {
110        P2ID_SCRIPT.clone()
111    }
112
113    /// Returns the P2ID (Pay-to-ID) note script root.
114    pub fn script_root() -> NoteScriptRoot {
115        P2ID_SCRIPT.root()
116    }
117
118    /// Returns the account ID of the note's sender.
119    pub fn sender(&self) -> AccountId {
120        self.sender
121    }
122
123    /// Returns the note's storage.
124    pub fn storage(&self) -> P2idNoteStorage {
125        self.storage
126    }
127
128    /// Returns the account ID of the note's target (the only account that can consume it).
129    pub fn target(&self) -> AccountId {
130        self.storage.target()
131    }
132
133    /// Returns the note's serial number.
134    pub fn serial_number(&self) -> Word {
135        self.serial_number
136    }
137
138    /// Returns the note's type.
139    pub fn note_type(&self) -> NoteType {
140        self.note_type
141    }
142
143    /// Returns the assets carried by the note.
144    pub fn assets(&self) -> &NoteAssets {
145        &self.assets
146    }
147
148    /// Returns the attachments carried by the note.
149    pub fn attachments(&self) -> &NoteAttachments {
150        &self.attachments
151    }
152}
153
154// BUILDER EXTENSIONS
155// ================================================================================================
156
157impl<S: p2id_note_builder::State> P2idNoteBuilder<S> {
158    /// Adds a single asset to the note. At least one asset is required for `.build()` to succeed.
159    pub fn asset(mut self, asset: impl Into<Asset>) -> Self {
160        self.assets.push(asset.into());
161        self
162    }
163
164    /// Adds multiple assets to the note.
165    pub fn assets(mut self, assets: impl IntoIterator<Item = impl Into<Asset>>) -> Self {
166        self.assets.extend(assets.into_iter().map(Into::into));
167        self
168    }
169
170    /// Adds a single attachment to the note.
171    pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
172        self.attachments.push(attachment.into());
173        self
174    }
175
176    /// Adds multiple attachments to the note.
177    pub fn attachments(
178        mut self,
179        attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
180    ) -> Self {
181        self.attachments.extend(attachments.into_iter().map(Into::into));
182        self
183    }
184}
185
186impl<S: p2id_note_builder::State> P2idNoteBuilder<S>
187where
188    S::SerialNumber: p2id_note_builder::IsUnset,
189{
190    /// Draws a serial number from `rng` and sets it on the builder.
191    pub fn generate_serial_number(
192        self,
193        rng: &mut impl FeltRng,
194    ) -> P2idNoteBuilder<p2id_note_builder::SetSerialNumber<S>> {
195        self.serial_number(rng.draw_word())
196    }
197}
198
199// CONVERSIONS
200// ================================================================================================
201
202impl From<P2idNote> for Note {
203    fn from(note: P2idNote) -> Self {
204        let recipient = note.storage.into_recipient(note.serial_number);
205        let tag = NoteTag::with_account_target(note.storage.target());
206        let metadata = PartialNoteMetadata::new(note.sender, note.note_type).with_tag(tag);
207
208        Note::with_attachments(note.assets, metadata, recipient, note.attachments)
209    }
210}
211
212// P2ID NOTE STORAGE
213// ================================================================================================
214
215/// Canonical storage representation for a P2ID note.
216///
217/// Contains the identifier of the target account that is authorized
218/// to consume the note. Only the account matching this ID can execute
219/// the note and claim its assets.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub struct P2idNoteStorage {
222    target: AccountId,
223}
224
225impl P2idNoteStorage {
226    // CONSTANTS
227    // --------------------------------------------------------------------------------------------
228
229    /// Expected number of storage items of the P2ID note.
230    pub const NUM_ITEMS: usize = 2;
231
232    /// Creates new P2ID note storage targeting the given account.
233    pub fn new(target: AccountId) -> Self {
234        Self { target }
235    }
236
237    /// Consumes the storage and returns a P2ID [`NoteRecipient`] with the provided serial number.
238    ///
239    /// Notes created with this recipient will be P2ID notes consumable by the specified target
240    /// account stored in this [`P2idNoteStorage`].
241    pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
242        NoteRecipient::new(serial_num, P2idNote::script(), NoteStorage::from(self))
243    }
244
245    /// Returns the target account ID.
246    pub fn target(&self) -> AccountId {
247        self.target
248    }
249}
250
251impl From<P2idNoteStorage> for NoteStorage {
252    fn from(storage: P2idNoteStorage) -> Self {
253        // Storage layout:
254        // [ account_id_suffix, account_id_prefix ]
255        NoteStorage::new(vec![storage.target.suffix(), storage.target.prefix().as_felt()])
256            .expect("number of storage items should not exceed max storage items")
257    }
258}
259
260impl TryFrom<&[Felt]> for P2idNoteStorage {
261    type Error = NoteError;
262
263    fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
264        if note_storage.len() != P2idNote::NUM_STORAGE_ITEMS {
265            return Err(NoteError::InvalidNoteStorageLength {
266                expected: P2idNote::NUM_STORAGE_ITEMS,
267                actual: note_storage.len(),
268            });
269        }
270
271        let target = AccountId::try_from_elements(note_storage[0], note_storage[1])
272            .map_err(|err| NoteError::other_with_source("failed to create account id", err))?;
273
274        Ok(Self { target })
275    }
276}
277
278// NOTE CONSUMPTION COST
279// ================================================================================================
280
281impl NoteConsumptionCost for P2idNote {
282    fn consumption_cycles() -> u32 {
283        P2ID_CONSUMPTION_CYCLES
284    }
285}
286
287// TESTS
288// ================================================================================================
289
290#[cfg(test)]
291mod tests {
292    use assert_matches::assert_matches;
293    use miden_protocol::account::{AccountId, AccountType};
294    use miden_protocol::asset::FungibleAsset;
295    use miden_protocol::crypto::rand::RandomCoin;
296    use miden_protocol::errors::NoteError;
297    use miden_protocol::{Felt, Word};
298
299    use super::*;
300
301    // STORAGE TESTS
302    // --------------------------------------------------------------------------------------------
303
304    #[test]
305    fn try_from_valid_storage_succeeds() {
306        let target = AccountId::builder()
307            .account_type(AccountType::Private)
308            .build_with_seed([1u8; 32]);
309
310        let storage = vec![target.suffix(), target.prefix().as_felt()];
311
312        let parsed =
313            P2idNoteStorage::try_from(storage.as_slice()).expect("storage should be valid");
314
315        assert_eq!(parsed.target(), target);
316    }
317
318    #[test]
319    fn try_from_invalid_length_returns_error() {
320        let storage = vec![Felt::ZERO];
321
322        let err = P2idNoteStorage::try_from(storage.as_slice())
323            .expect_err("should fail due to invalid length");
324
325        assert!(matches!(
326            err,
327            NoteError::InvalidNoteStorageLength {
328                expected: P2idNote::NUM_STORAGE_ITEMS,
329                actual: 1
330            }
331        ));
332    }
333
334    #[test]
335    fn try_from_invalid_storage_contents_returns_error() {
336        let storage = vec![Felt::new_unchecked(999_u64), Felt::new_unchecked(888_u64)];
337
338        let err = P2idNoteStorage::try_from(storage.as_slice())
339            .expect_err("should fail due to invalid account id encoding");
340
341        assert!(matches!(err, NoteError::Other { source: Some(_), .. }));
342    }
343
344    // BUILDER TESTS
345    // --------------------------------------------------------------------------------------------
346
347    fn sender() -> AccountId {
348        AccountId::builder()
349            .account_type(AccountType::Private)
350            .build_with_seed([1u8; 32])
351    }
352
353    fn target() -> AccountId {
354        AccountId::builder()
355            .account_type(AccountType::Private)
356            .build_with_seed([2u8; 32])
357    }
358
359    fn faucet_a() -> AccountId {
360        AccountId::builder()
361            .account_type(AccountType::Public)
362            .build_with_seed([3u8; 32])
363    }
364
365    fn faucet_b() -> AccountId {
366        AccountId::builder()
367            .account_type(AccountType::Public)
368            .build_with_seed([4u8; 32])
369    }
370
371    /// The minimal builder uses defaults for everything but the required fields.
372    #[test]
373    fn builder_minimal_uses_defaults() {
374        let note = P2idNote::builder()
375            .sender(sender())
376            .target(target())
377            .serial_number(Word::empty())
378            .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
379            .build()
380            .unwrap();
381
382        assert_eq!(note.sender(), sender());
383        assert_eq!(note.target(), target());
384        assert_eq!(note.note_type(), NoteType::default());
385        assert_eq!(note.assets().num_assets(), 1);
386        assert_eq!(note.attachments().num_attachments(), 0);
387    }
388
389    /// `.asset()` and `.assets()` both append, so they can be combined and called repeatedly.
390    #[test]
391    fn builder_accumulates_assets() {
392        let mut rng = RandomCoin::new(Word::empty());
393        let note = P2idNote::builder()
394            .sender(sender())
395            .target(target())
396            .asset(FungibleAsset::new(faucet_a(), 100).unwrap())
397            .assets([Asset::from(FungibleAsset::new(faucet_b(), 200).unwrap())])
398            .generate_serial_number(&mut rng)
399            .build()
400            .unwrap();
401
402        assert_eq!(note.assets().num_assets(), 2);
403        assert_ne!(note.serial_number(), Word::empty());
404    }
405
406    /// A P2ID note must carry at least one asset.
407    #[test]
408    fn builder_rejects_empty_assets() {
409        let err = P2idNote::builder()
410            .sender(sender())
411            .target(target())
412            .serial_number(Word::empty())
413            .build()
414            .expect_err("a note without assets must be rejected");
415
416        assert_matches!(err, NoteError::Other { error_msg, .. } => {
417            assert!(error_msg.contains("note must contain at least one asset"))
418        });
419    }
420}