Skip to main content

miden_standards/note/
p2ide.rs

1use alloc::vec::Vec;
2
3use miden_protocol::account::AccountId;
4use miden_protocol::assembly::Path;
5use miden_protocol::asset::Asset;
6use miden_protocol::block::BlockNumber;
7use miden_protocol::crypto::rand::FeltRng;
8use miden_protocol::errors::NoteError;
9use miden_protocol::note::{
10    Note,
11    NoteAssets,
12    NoteAttachment,
13    NoteMetadata,
14    NoteRecipient,
15    NoteScript,
16    NoteStorage,
17    NoteTag,
18    NoteType,
19};
20use miden_protocol::utils::sync::LazyLock;
21use miden_protocol::{Felt, FieldElement, Word};
22
23use crate::StandardsLib;
24// NOTE SCRIPT
25// ================================================================================================
26
27/// Path to the P2IDE note script procedure in the standards library.
28const P2IDE_SCRIPT_PATH: &str = "::miden::standards::notes::p2ide::main";
29
30// Initialize the P2IDE note script only once
31static P2IDE_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
32    let standards_lib = StandardsLib::default();
33    let path = Path::new(P2IDE_SCRIPT_PATH);
34    NoteScript::from_library_reference(standards_lib.as_ref(), path)
35        .expect("Standards library contains P2IDE note script procedure")
36});
37
38// P2IDE NOTE
39// ================================================================================================
40
41/// Pay-to-ID Extended (P2IDE) note abstraction.
42///
43/// A P2IDE note enables transferring assets to a target account specified in the note storage.
44/// The note may optionally include:
45///
46/// - A reclaim height allowing the sender to recover assets if the note remains unconsumed
47/// - A timelock height preventing consumption before a given block
48///
49/// These constraints are encoded in `P2ideNoteStorage` and enforced by the associated note script.
50pub struct P2ideNote;
51
52impl P2ideNote {
53    // CONSTANTS
54    // --------------------------------------------------------------------------------------------
55
56    /// Expected number of storage items of the P2IDE note.
57    pub const NUM_STORAGE_ITEMS: usize = P2ideNoteStorage::NUM_ITEMS;
58
59    // PUBLIC ACCESSORS
60    // --------------------------------------------------------------------------------------------
61
62    /// Returns the script of the P2IDE (Pay-to-ID extended) note.
63    pub fn script() -> NoteScript {
64        P2IDE_SCRIPT.clone()
65    }
66
67    /// Returns the P2IDE (Pay-to-ID extended) note script root.
68    pub fn script_root() -> Word {
69        P2IDE_SCRIPT.root()
70    }
71
72    // BUILDERS
73    // --------------------------------------------------------------------------------------------
74
75    /// Generates a P2IDE note using the provided storage configuration.
76    ///
77    /// The note recipient and execution constraints are derived from
78    /// `P2ideNoteStorage`. A random serial number is generated using `rng`,
79    /// and the note tag is set to the storage target account.
80    ///
81    /// # Errors
82    /// Returns an error if construction of the note recipient or asset vault fails.
83    pub fn create<R: FeltRng>(
84        sender: AccountId,
85        storage: P2ideNoteStorage,
86        assets: Vec<Asset>,
87        note_type: NoteType,
88        attachment: NoteAttachment,
89        rng: &mut R,
90    ) -> Result<Note, NoteError> {
91        let serial_num = rng.draw_word();
92        let recipient = storage.into_recipient(serial_num)?;
93        let tag = NoteTag::with_account_target(storage.target());
94
95        let metadata =
96            NoteMetadata::new(sender, note_type).with_tag(tag).with_attachment(attachment);
97        let vault = NoteAssets::new(assets)?;
98
99        Ok(Note::new(vault, metadata, recipient))
100    }
101}
102
103// P2IDE NOTE STORAGE
104// ================================================================================================
105
106/// Canonical storage representation for a P2IDE note.
107///
108/// Stores the target account ID together with optional
109/// reclaim and timelock constraints controlling when
110/// the note can be spent or reclaimed.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct P2ideNoteStorage {
113    pub target: AccountId,
114    pub reclaim_height: Option<BlockNumber>,
115    pub timelock_height: Option<BlockNumber>,
116}
117
118impl P2ideNoteStorage {
119    // CONSTANTS
120    // --------------------------------------------------------------------------------------------
121
122    /// Expected number of storage items of the P2IDE note.
123    pub const NUM_ITEMS: usize = 4;
124
125    /// Creates new P2IDE note storage.
126    pub fn new(
127        target: AccountId,
128        reclaim_height: Option<BlockNumber>,
129        timelock_height: Option<BlockNumber>,
130    ) -> Self {
131        Self { target, reclaim_height, timelock_height }
132    }
133
134    /// Consumes the storage and returns a P2IDE [`NoteRecipient`] with the provided serial number.
135    pub fn into_recipient(self, serial_num: Word) -> Result<NoteRecipient, NoteError> {
136        let note_script = P2ideNote::script();
137        Ok(NoteRecipient::new(serial_num, note_script, self.into()))
138    }
139
140    /// Returns the target account ID.
141    pub fn target(&self) -> AccountId {
142        self.target
143    }
144
145    /// Returns the reclaim block height (if any).
146    pub fn reclaim_height(&self) -> Option<BlockNumber> {
147        self.reclaim_height
148    }
149
150    /// Returns the timelock block height (if any).
151    pub fn timelock_height(&self) -> Option<BlockNumber> {
152        self.timelock_height
153    }
154}
155
156impl From<P2ideNoteStorage> for NoteStorage {
157    fn from(storage: P2ideNoteStorage) -> Self {
158        let reclaim = storage.reclaim_height.map(Felt::from).unwrap_or(Felt::ZERO);
159        let timelock = storage.timelock_height.map(Felt::from).unwrap_or(Felt::ZERO);
160
161        NoteStorage::new(vec![
162            storage.target.suffix(),
163            storage.target.prefix().as_felt(),
164            reclaim,
165            timelock,
166        ])
167        .expect("number of storage items should not exceed max storage items")
168    }
169}
170
171impl TryFrom<&[Felt]> for P2ideNoteStorage {
172    type Error = NoteError;
173
174    fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
175        if note_storage.len() != P2ideNote::NUM_STORAGE_ITEMS {
176            return Err(NoteError::InvalidNoteStorageLength {
177                expected: P2ideNote::NUM_STORAGE_ITEMS,
178                actual: note_storage.len(),
179            });
180        }
181
182        let target = AccountId::try_from([note_storage[1], note_storage[0]])
183            .map_err(|e| NoteError::other_with_source("failed to create account id", e))?;
184
185        let reclaim_height = if note_storage[2] == Felt::ZERO {
186            None
187        } else {
188            let height: u32 = note_storage[2]
189                .as_int()
190                .try_into()
191                .map_err(|e| NoteError::other_with_source("invalid note storage", e))?;
192
193            Some(BlockNumber::from(height))
194        };
195
196        let timelock_height = if note_storage[3] == Felt::ZERO {
197            None
198        } else {
199            let height: u32 = note_storage[3]
200                .as_int()
201                .try_into()
202                .map_err(|e| NoteError::other_with_source("invalid note storage", e))?;
203
204            Some(BlockNumber::from(height))
205        };
206
207        Ok(Self { target, reclaim_height, timelock_height })
208    }
209}
210
211// TESTS
212// ================================================================================================
213
214#[cfg(test)]
215mod tests {
216    use miden_protocol::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType};
217    use miden_protocol::block::BlockNumber;
218    use miden_protocol::errors::NoteError;
219    use miden_protocol::{Felt, FieldElement};
220
221    use super::*;
222
223    fn dummy_account() -> AccountId {
224        AccountId::dummy(
225            [3u8; 15],
226            AccountIdVersion::Version0,
227            AccountType::FungibleFaucet,
228            AccountStorageMode::Private,
229        )
230    }
231
232    #[test]
233    fn try_from_valid_storage_with_all_fields_succeeds() {
234        let target = dummy_account();
235
236        let storage = vec![
237            target.suffix(),
238            target.prefix().as_felt(),
239            Felt::from(42u32),
240            Felt::from(100u32),
241        ];
242
243        let decoded = P2ideNoteStorage::try_from(storage.as_slice())
244            .expect("valid P2IDE storage should decode");
245
246        assert_eq!(decoded.target(), target);
247        assert_eq!(decoded.reclaim_height(), Some(BlockNumber::from(42u32)));
248        assert_eq!(decoded.timelock_height(), Some(BlockNumber::from(100u32)));
249    }
250
251    #[test]
252    fn try_from_zero_heights_map_to_none() {
253        let target = dummy_account();
254
255        let storage = vec![target.suffix(), target.prefix().as_felt(), Felt::ZERO, Felt::ZERO];
256
257        let decoded = P2ideNoteStorage::try_from(storage.as_slice()).unwrap();
258
259        assert_eq!(decoded.reclaim_height(), None);
260        assert_eq!(decoded.timelock_height(), None);
261    }
262
263    #[test]
264    fn try_from_invalid_length_fails() {
265        let storage = vec![Felt::ZERO; 3];
266
267        let err =
268            P2ideNoteStorage::try_from(storage.as_slice()).expect_err("wrong length must fail");
269
270        assert!(matches!(
271            err,
272            NoteError::InvalidNoteStorageLength {
273                expected: P2ideNote::NUM_STORAGE_ITEMS,
274                actual: 3
275            }
276        ));
277    }
278
279    #[test]
280    fn try_from_invalid_account_id_fails() {
281        let storage = vec![Felt::new(999u64), Felt::new(888u64), Felt::ZERO, Felt::ZERO];
282
283        let err = P2ideNoteStorage::try_from(storage.as_slice())
284            .expect_err("invalid account id encoding must fail");
285
286        assert!(matches!(err, NoteError::Other { source: Some(_), .. }));
287    }
288
289    #[test]
290    fn try_from_reclaim_height_overflow_fails() {
291        let target = dummy_account();
292
293        // > u32::MAX
294        let overflow = Felt::new(u64::from(u32::MAX) + 1);
295
296        let storage = vec![target.suffix(), target.prefix().as_felt(), overflow, Felt::ZERO];
297
298        let err = P2ideNoteStorage::try_from(storage.as_slice())
299            .expect_err("overflow reclaim height must fail");
300
301        assert!(matches!(err, NoteError::Other { source: Some(_), .. }));
302    }
303
304    #[test]
305    fn try_from_timelock_height_overflow_fails() {
306        let target = dummy_account();
307
308        let overflow = Felt::new(u64::from(u32::MAX) + 10);
309
310        let storage = vec![target.suffix(), target.prefix().as_felt(), Felt::ZERO, overflow];
311
312        let err = P2ideNoteStorage::try_from(storage.as_slice())
313            .expect_err("overflow timelock height must fail");
314
315        assert!(matches!(err, NoteError::Other { source: Some(_), .. }));
316    }
317}