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 NoteAttachments,
14 NoteRecipient,
15 NoteScript,
16 NoteScriptRoot,
17 NoteStorage,
18 NoteTag,
19 NoteType,
20 PartialNoteMetadata,
21};
22use miden_protocol::utils::sync::LazyLock;
23use miden_protocol::{Felt, Word};
24
25use crate::StandardsLib;
26const P2IDE_SCRIPT_PATH: &str = "::miden::standards::notes::p2ide::main";
31
32static P2IDE_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
34 let standards_lib = StandardsLib::default();
35 let path = Path::new(P2IDE_SCRIPT_PATH);
36 NoteScript::from_library_reference(standards_lib.as_ref(), path)
37 .expect("Standards library contains P2IDE note script procedure")
38});
39
40#[derive(Debug, Clone)]
53pub struct P2ideNote {
54 sender: AccountId,
55 storage: P2ideNoteStorage,
56 serial_number: Word,
57 note_type: NoteType,
58 assets: NoteAssets,
59 attachments: NoteAttachments,
60}
61
62#[bon::bon]
63impl P2ideNote {
64 #[builder]
73 pub fn new(
74 #[builder(field)] assets: Vec<Asset>,
75 #[builder(field)] attachments: Vec<NoteAttachment>,
76 sender: AccountId,
77 target: AccountId,
78 reclaimer: Option<AccountId>,
79 reclaim_height: Option<BlockNumber>,
80 timelock_height: Option<BlockNumber>,
81 serial_number: Word,
82 #[builder(default)] note_type: NoteType,
83 ) -> Result<Self, NoteError> {
84 if assets.is_empty() {
85 return Err(NoteError::other("a P2IDE note must contain at least one asset"));
86 }
87
88 let reclaimer = reclaimer.unwrap_or(sender);
90 let storage = P2ideNoteStorage::new(reclaimer, target, reclaim_height, timelock_height);
91 let assets = NoteAssets::new(assets)?;
92 let attachments = NoteAttachments::new(attachments)?;
93
94 Ok(Self {
95 sender,
96 storage,
97 serial_number,
98 note_type,
99 assets,
100 attachments,
101 })
102 }
103}
104
105impl P2ideNote {
106 pub const NUM_STORAGE_ITEMS: usize = P2ideNoteStorage::NUM_ITEMS;
111
112 pub fn script() -> NoteScript {
117 P2IDE_SCRIPT.clone()
118 }
119
120 pub fn script_root() -> NoteScriptRoot {
122 P2IDE_SCRIPT.root()
123 }
124
125 pub fn sender(&self) -> AccountId {
127 self.sender
128 }
129
130 pub fn storage(&self) -> P2ideNoteStorage {
132 self.storage
133 }
134
135 pub fn target(&self) -> AccountId {
137 self.storage.target()
138 }
139
140 pub fn reclaimer(&self) -> AccountId {
142 self.storage.reclaimer()
143 }
144
145 pub fn reclaim_height(&self) -> Option<BlockNumber> {
147 self.storage.reclaim_height()
148 }
149
150 pub fn timelock_height(&self) -> Option<BlockNumber> {
152 self.storage.timelock_height()
153 }
154
155 pub fn serial_number(&self) -> Word {
157 self.serial_number
158 }
159
160 pub fn note_type(&self) -> NoteType {
162 self.note_type
163 }
164
165 pub fn assets(&self) -> &NoteAssets {
167 &self.assets
168 }
169
170 pub fn attachments(&self) -> &NoteAttachments {
172 &self.attachments
173 }
174}
175
176impl<S: p2ide_note_builder::State> P2ideNoteBuilder<S> {
180 pub fn asset(mut self, asset: impl Into<Asset>) -> Self {
182 self.assets.push(asset.into());
183 self
184 }
185
186 pub fn assets(mut self, assets: impl IntoIterator<Item = impl Into<Asset>>) -> Self {
188 self.assets.extend(assets.into_iter().map(Into::into));
189 self
190 }
191
192 pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
194 self.attachments.push(attachment.into());
195 self
196 }
197
198 pub fn attachments(
200 mut self,
201 attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
202 ) -> Self {
203 self.attachments.extend(attachments.into_iter().map(Into::into));
204 self
205 }
206}
207
208impl<S: p2ide_note_builder::State> P2ideNoteBuilder<S>
209where
210 S::SerialNumber: p2ide_note_builder::IsUnset,
211{
212 pub fn generate_serial_number(
214 self,
215 rng: &mut impl FeltRng,
216 ) -> P2ideNoteBuilder<p2ide_note_builder::SetSerialNumber<S>> {
217 self.serial_number(rng.draw_word())
218 }
219}
220
221impl From<P2ideNote> for Note {
225 fn from(note: P2ideNote) -> Self {
226 let recipient = note.storage.into_recipient(note.serial_number);
227 let tag = NoteTag::with_account_target(note.storage.target());
228 let metadata = PartialNoteMetadata::new(note.sender, note.note_type).with_tag(tag);
229
230 Note::with_attachments(note.assets, metadata, recipient, note.attachments)
231 }
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct P2ideNoteStorage {
243 reclaimer: AccountId,
244 target: AccountId,
245 reclaim_height: Option<BlockNumber>,
246 timelock_height: Option<BlockNumber>,
247}
248
249impl P2ideNoteStorage {
250 pub const NUM_ITEMS: usize = 6;
255
256 const RECLAIMER_SUFFIX_IDX: usize = 0;
259 const RECLAIMER_PREFIX_IDX: usize = 1;
260 const TARGET_SUFFIX_IDX: usize = 2;
261 const TARGET_PREFIX_IDX: usize = 3;
262 const RECLAIM_HEIGHT_IDX: usize = 4;
263 const TIMELOCK_HEIGHT_IDX: usize = 5;
264
265 pub fn new(
267 reclaimer: AccountId,
268 target: AccountId,
269 reclaim_height: Option<BlockNumber>,
270 timelock_height: Option<BlockNumber>,
271 ) -> Self {
272 Self {
273 reclaimer,
274 target,
275 reclaim_height,
276 timelock_height,
277 }
278 }
279
280 pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
282 NoteRecipient::new(serial_num, P2ideNote::script(), self.into())
283 }
284
285 pub fn reclaimer(&self) -> AccountId {
287 self.reclaimer
288 }
289
290 pub fn target(&self) -> AccountId {
292 self.target
293 }
294
295 pub fn reclaim_height(&self) -> Option<BlockNumber> {
297 self.reclaim_height
298 }
299
300 pub fn timelock_height(&self) -> Option<BlockNumber> {
302 self.timelock_height
303 }
304}
305
306impl From<P2ideNoteStorage> for NoteStorage {
307 fn from(storage: P2ideNoteStorage) -> Self {
308 let reclaim = storage.reclaim_height.map_or(Felt::ZERO, Felt::from);
310 let timelock = storage.timelock_height.map_or(Felt::ZERO, Felt::from);
311
312 NoteStorage::new(vec![
314 storage.reclaimer.suffix(),
315 storage.reclaimer.prefix().as_felt(),
316 storage.target.suffix(),
317 storage.target.prefix().as_felt(),
318 reclaim,
319 timelock,
320 ])
321 .expect("number of storage items should not exceed max storage items")
322 }
323}
324
325impl TryFrom<&[Felt]> for P2ideNoteStorage {
326 type Error = NoteError;
327
328 fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
329 if note_storage.len() != P2ideNote::NUM_STORAGE_ITEMS {
330 return Err(NoteError::InvalidNoteStorageLength {
331 expected: P2ideNote::NUM_STORAGE_ITEMS,
332 actual: note_storage.len(),
333 });
334 }
335
336 let reclaimer = AccountId::try_from_elements(
337 note_storage[Self::RECLAIMER_SUFFIX_IDX],
338 note_storage[Self::RECLAIMER_PREFIX_IDX],
339 )
340 .map_err(|err| {
341 NoteError::other_with_source("failed to create reclaimer account id", err)
342 })?;
343
344 let target = AccountId::try_from_elements(
345 note_storage[Self::TARGET_SUFFIX_IDX],
346 note_storage[Self::TARGET_PREFIX_IDX],
347 )
348 .map_err(|err| NoteError::other_with_source("failed to create target account id", err))?;
349
350 let reclaim_height = decode_block_height(
351 note_storage[Self::RECLAIM_HEIGHT_IDX],
352 "invalid reclaim height in note storage",
353 )?;
354 let timelock_height = decode_block_height(
355 note_storage[Self::TIMELOCK_HEIGHT_IDX],
356 "invalid timelock height in note storage",
357 )?;
358
359 Ok(Self {
360 reclaimer,
361 target,
362 reclaim_height,
363 timelock_height,
364 })
365 }
366}
367
368fn decode_block_height(
372 item: Felt,
373 error_msg: &'static str,
374) -> Result<Option<BlockNumber>, NoteError> {
375 if item == Felt::ZERO {
376 return Ok(None);
377 }
378
379 let height: u32 = item
380 .as_canonical_u64()
381 .try_into()
382 .map_err(|e| NoteError::other_with_source(error_msg, e))?;
383
384 Ok(Some(BlockNumber::from(height)))
385}
386
387#[cfg(test)]
391mod tests {
392 use assert_matches::assert_matches;
393 use miden_protocol::account::{AccountId, AccountType};
394 use miden_protocol::asset::FungibleAsset;
395 use miden_protocol::block::BlockNumber;
396 use miden_protocol::crypto::rand::RandomCoin;
397 use miden_protocol::errors::NoteError;
398 use miden_protocol::{Felt, Word};
399
400 use super::*;
401
402 const INVALID_ID_SUFFIX: Felt = Felt::new_unchecked(999);
407 const INVALID_ID_PREFIX: Felt = Felt::new_unchecked(888);
408
409 fn dummy_account() -> AccountId {
410 AccountId::builder()
411 .account_type(AccountType::Private)
412 .build_with_seed([3u8; 32])
413 }
414
415 #[test]
419 fn try_from_valid_storage_with_all_fields_succeeds() {
420 let reclaimer = sender();
421 let target = dummy_account();
422
423 let storage = vec![
424 reclaimer.suffix(),
425 reclaimer.prefix().as_felt(),
426 target.suffix(),
427 target.prefix().as_felt(),
428 Felt::from(42u32),
429 Felt::from(100u32),
430 ];
431
432 let decoded = P2ideNoteStorage::try_from(storage.as_slice())
433 .expect("valid P2IDE storage should decode");
434
435 assert_eq!(decoded.reclaimer(), reclaimer);
436 assert_eq!(decoded.target(), target);
437 assert_eq!(decoded.reclaim_height(), Some(BlockNumber::from(42u32)));
438 assert_eq!(decoded.timelock_height(), Some(BlockNumber::from(100u32)));
439 }
440
441 #[test]
442 fn try_from_zero_heights_map_to_none() {
443 let reclaimer = sender();
444 let target = dummy_account();
445
446 let storage = vec![
447 reclaimer.suffix(),
448 reclaimer.prefix().as_felt(),
449 target.suffix(),
450 target.prefix().as_felt(),
451 Felt::ZERO,
452 Felt::ZERO,
453 ];
454
455 let decoded = P2ideNoteStorage::try_from(storage.as_slice()).unwrap();
456
457 assert_eq!(decoded.reclaim_height(), None);
458 assert_eq!(decoded.timelock_height(), None);
459 }
460
461 #[test]
462 fn try_from_invalid_length_fails() {
463 let storage = vec![Felt::ZERO; 3];
464
465 let err =
466 P2ideNoteStorage::try_from(storage.as_slice()).expect_err("wrong length must fail");
467
468 assert!(matches!(
469 err,
470 NoteError::InvalidNoteStorageLength {
471 expected: P2ideNote::NUM_STORAGE_ITEMS,
472 actual: 3
473 }
474 ));
475 }
476
477 #[test]
480 fn try_from_invalid_reclaimer_fails() {
481 let target = dummy_account();
482
483 let storage = vec![
484 INVALID_ID_SUFFIX,
485 INVALID_ID_PREFIX,
486 target.suffix(),
487 target.prefix().as_felt(),
488 Felt::ZERO,
489 Felt::ZERO,
490 ];
491
492 let err = P2ideNoteStorage::try_from(storage.as_slice())
493 .expect_err("invalid reclaimer encoding must fail");
494
495 assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
496 assert!(error_msg.contains("reclaimer"));
497 });
498 }
499
500 #[test]
501 fn try_from_invalid_target_fails() {
502 let reclaimer = sender();
503
504 let storage = vec![
505 reclaimer.suffix(),
506 reclaimer.prefix().as_felt(),
507 INVALID_ID_SUFFIX,
508 INVALID_ID_PREFIX,
509 Felt::ZERO,
510 Felt::ZERO,
511 ];
512
513 let err = P2ideNoteStorage::try_from(storage.as_slice())
514 .expect_err("invalid target encoding must fail");
515
516 assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
517 assert!(error_msg.contains("target"));
518 });
519 }
520
521 #[test]
529 fn storage_round_trips_through_note_storage() {
530 let storage = P2ideNoteStorage::new(
531 sender(),
532 target(),
533 Some(BlockNumber::from(42u32)),
534 Some(BlockNumber::from(100u32)),
535 );
536
537 let encoded: NoteStorage = storage.into();
538 let decoded = P2ideNoteStorage::try_from(encoded.items()).unwrap();
539
540 assert_eq!(decoded, storage);
541 }
542
543 #[test]
547 fn zero_reclaim_height_means_reclaim_disabled() {
548 let storage = P2ideNoteStorage::new(sender(), target(), Some(BlockNumber::GENESIS), None);
549
550 let encoded: NoteStorage = storage.into();
551 let decoded = P2ideNoteStorage::try_from(encoded.items()).unwrap();
552
553 assert_eq!(decoded.reclaim_height(), None);
554 }
555
556 #[test]
557 fn try_from_reclaim_height_overflow_fails() {
558 let reclaimer = sender();
559 let target = dummy_account();
560
561 let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 1);
563
564 let storage = vec![
565 reclaimer.suffix(),
566 reclaimer.prefix().as_felt(),
567 target.suffix(),
568 target.prefix().as_felt(),
569 overflow,
570 Felt::ZERO,
571 ];
572
573 let err = P2ideNoteStorage::try_from(storage.as_slice())
574 .expect_err("overflow reclaim height must fail");
575
576 assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
577 assert!(error_msg.contains("reclaim height"));
578 });
579 }
580
581 #[test]
582 fn try_from_timelock_height_overflow_fails() {
583 let reclaimer = sender();
584 let target = dummy_account();
585
586 let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 10);
587
588 let storage = vec![
589 reclaimer.suffix(),
590 reclaimer.prefix().as_felt(),
591 target.suffix(),
592 target.prefix().as_felt(),
593 Felt::ZERO,
594 overflow,
595 ];
596
597 let err = P2ideNoteStorage::try_from(storage.as_slice())
598 .expect_err("overflow timelock height must fail");
599
600 assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
601 assert!(error_msg.contains("timelock height"));
602 });
603 }
604
605 fn sender() -> AccountId {
609 AccountId::builder()
610 .account_type(AccountType::Private)
611 .build_with_seed([1u8; 32])
612 }
613
614 fn target() -> AccountId {
615 AccountId::builder()
616 .account_type(AccountType::Private)
617 .build_with_seed([2u8; 32])
618 }
619
620 fn faucet_a() -> AccountId {
621 AccountId::builder()
622 .account_type(AccountType::Public)
623 .build_with_seed([3u8; 32])
624 }
625
626 fn faucet_b() -> AccountId {
627 AccountId::builder()
628 .account_type(AccountType::Public)
629 .build_with_seed([4u8; 32])
630 }
631
632 #[test]
635 fn builder_minimal_uses_defaults() {
636 let note = P2ideNote::builder()
637 .sender(sender())
638 .target(target())
639 .serial_number(Word::empty())
640 .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
641 .build()
642 .unwrap();
643
644 assert_eq!(note.sender(), sender());
645 assert_eq!(note.target(), target());
646 assert_eq!(note.reclaimer(), sender());
648 assert_eq!(note.note_type(), NoteType::default());
649 assert_eq!(note.reclaim_height(), None);
650 assert_eq!(note.timelock_height(), None);
651 assert_eq!(note.assets().num_assets(), 1);
652 assert_eq!(note.attachments().num_attachments(), 0);
653 }
654
655 #[test]
657 fn builder_accumulates_assets() {
658 let mut rng = RandomCoin::new(Word::empty());
659 let note = P2ideNote::builder()
660 .sender(sender())
661 .target(target())
662 .asset(FungibleAsset::new(faucet_a(), 100).unwrap())
663 .assets([Asset::from(FungibleAsset::new(faucet_b(), 200).unwrap())])
664 .generate_serial_number(&mut rng)
665 .build()
666 .unwrap();
667
668 assert_eq!(note.assets().num_assets(), 2);
669 assert_ne!(note.serial_number(), Word::empty());
670 }
671
672 #[test]
674 fn builder_rejects_empty_assets() {
675 let err = P2ideNote::builder()
676 .sender(sender())
677 .target(target())
678 .serial_number(Word::empty())
679 .build()
680 .expect_err("a note without assets must be rejected");
681
682 assert_matches!(err, NoteError::Other { error_msg, .. } => {
683 assert!(error_msg.contains("note must contain at least one asset"))
684 });
685 }
686
687 #[test]
689 fn builder_sets_reclaim_and_timelock() {
690 let note = P2ideNote::builder()
691 .sender(sender())
692 .target(target())
693 .serial_number(Word::empty())
694 .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
695 .reclaim_height(BlockNumber::from(42u32))
696 .timelock_height(BlockNumber::from(100u32))
697 .build()
698 .unwrap();
699
700 assert_eq!(note.reclaim_height(), Some(BlockNumber::from(42u32)));
701 assert_eq!(note.timelock_height(), Some(BlockNumber::from(100u32)));
702 }
703
704 #[test]
707 fn builder_explicit_reclaimer_differs_from_sender() {
708 let note = P2ideNote::builder()
709 .sender(sender())
710 .target(target())
711 .reclaimer(dummy_account())
712 .serial_number(Word::empty())
713 .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
714 .build()
715 .unwrap();
716
717 assert_eq!(note.sender(), sender());
718 assert_eq!(note.reclaimer(), dummy_account());
719 assert_ne!(note.reclaimer(), note.sender());
720
721 let storage: NoteStorage = note.storage().into();
723 let decoded = P2ideNoteStorage::try_from(storage.items()).unwrap();
724 assert_eq!(decoded.reclaimer(), dummy_account());
725 assert_eq!(decoded.target(), target());
726 }
727}