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 super::decode_optional_block_height;
26use crate::StandardsLib;
27use crate::note::costs::{NoteConsumptionCost, P2IDE_CONSUMPTION_CYCLES};
28const P2IDE_SCRIPT_PATH: &str = "::miden::standards::notes::p2ide::main";
33
34static P2IDE_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
36 let standards_lib = StandardsLib::default();
37 let path = Path::new(P2IDE_SCRIPT_PATH);
38 NoteScript::from_package_reference(standards_lib.as_ref(), path)
39 .expect("Standards library contains P2IDE note script procedure")
40});
41
42#[derive(Debug, Clone)]
55pub struct P2ideNote {
56 sender: AccountId,
57 storage: P2ideNoteStorage,
58 serial_number: Word,
59 note_type: NoteType,
60 assets: NoteAssets,
61 attachments: NoteAttachments,
62}
63
64#[bon::bon]
65impl P2ideNote {
66 #[builder]
75 pub fn new(
76 #[builder(field)] assets: Vec<Asset>,
77 #[builder(field)] attachments: Vec<NoteAttachment>,
78 sender: AccountId,
79 target: AccountId,
80 reclaimer: Option<AccountId>,
81 reclaim_height: Option<BlockNumber>,
82 timelock_height: Option<BlockNumber>,
83 serial_number: Word,
84 #[builder(default)] note_type: NoteType,
85 ) -> Result<Self, NoteError> {
86 if assets.is_empty() {
87 return Err(NoteError::other("a P2IDE note must contain at least one asset"));
88 }
89
90 let reclaimer = reclaimer.unwrap_or(sender);
92 let storage = P2ideNoteStorage::new(reclaimer, target, reclaim_height, timelock_height);
93 let assets = NoteAssets::new(assets)?;
94 let attachments = NoteAttachments::new(attachments)?;
95
96 Ok(Self {
97 sender,
98 storage,
99 serial_number,
100 note_type,
101 assets,
102 attachments,
103 })
104 }
105}
106
107impl P2ideNote {
108 pub const NUM_STORAGE_ITEMS: usize = P2ideNoteStorage::NUM_ITEMS;
113
114 pub fn script() -> NoteScript {
119 P2IDE_SCRIPT.clone()
120 }
121
122 pub fn script_root() -> NoteScriptRoot {
124 P2IDE_SCRIPT.root()
125 }
126
127 pub fn sender(&self) -> AccountId {
129 self.sender
130 }
131
132 pub fn storage(&self) -> P2ideNoteStorage {
134 self.storage
135 }
136
137 pub fn target(&self) -> AccountId {
139 self.storage.target()
140 }
141
142 pub fn reclaimer(&self) -> AccountId {
144 self.storage.reclaimer()
145 }
146
147 pub fn reclaim_height(&self) -> Option<BlockNumber> {
149 self.storage.reclaim_height()
150 }
151
152 pub fn timelock_height(&self) -> Option<BlockNumber> {
154 self.storage.timelock_height()
155 }
156
157 pub fn serial_number(&self) -> Word {
159 self.serial_number
160 }
161
162 pub fn note_type(&self) -> NoteType {
164 self.note_type
165 }
166
167 pub fn assets(&self) -> &NoteAssets {
169 &self.assets
170 }
171
172 pub fn attachments(&self) -> &NoteAttachments {
174 &self.attachments
175 }
176}
177
178impl<S: p2ide_note_builder::State> P2ideNoteBuilder<S> {
182 pub fn asset(mut self, asset: impl Into<Asset>) -> Self {
184 self.assets.push(asset.into());
185 self
186 }
187
188 pub fn assets(mut self, assets: impl IntoIterator<Item = impl Into<Asset>>) -> Self {
190 self.assets.extend(assets.into_iter().map(Into::into));
191 self
192 }
193
194 pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
196 self.attachments.push(attachment.into());
197 self
198 }
199
200 pub fn attachments(
202 mut self,
203 attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
204 ) -> Self {
205 self.attachments.extend(attachments.into_iter().map(Into::into));
206 self
207 }
208}
209
210impl<S: p2ide_note_builder::State> P2ideNoteBuilder<S>
211where
212 S::SerialNumber: p2ide_note_builder::IsUnset,
213{
214 pub fn generate_serial_number(
216 self,
217 rng: &mut impl FeltRng,
218 ) -> P2ideNoteBuilder<p2ide_note_builder::SetSerialNumber<S>> {
219 self.serial_number(rng.draw_word())
220 }
221}
222
223impl From<P2ideNote> for Note {
227 fn from(note: P2ideNote) -> Self {
228 let recipient = note.storage.into_recipient(note.serial_number);
229 let tag = NoteTag::with_account_target(note.storage.target());
230 let metadata = PartialNoteMetadata::new(note.sender, note.note_type).with_tag(tag);
231
232 Note::with_attachments(note.assets, metadata, recipient, note.attachments)
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub struct P2ideNoteStorage {
245 reclaimer: AccountId,
246 target: AccountId,
247 reclaim_height: Option<BlockNumber>,
248 timelock_height: Option<BlockNumber>,
249}
250
251impl P2ideNoteStorage {
252 pub const NUM_ITEMS: usize = 6;
257
258 const RECLAIMER_SUFFIX_IDX: usize = 0;
261 const RECLAIMER_PREFIX_IDX: usize = 1;
262 const TARGET_SUFFIX_IDX: usize = 2;
263 const TARGET_PREFIX_IDX: usize = 3;
264 const RECLAIM_HEIGHT_IDX: usize = 4;
265 const TIMELOCK_HEIGHT_IDX: usize = 5;
266
267 pub fn new(
269 reclaimer: AccountId,
270 target: AccountId,
271 reclaim_height: Option<BlockNumber>,
272 timelock_height: Option<BlockNumber>,
273 ) -> Self {
274 Self {
275 reclaimer,
276 target,
277 reclaim_height,
278 timelock_height,
279 }
280 }
281
282 pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
284 NoteRecipient::new(serial_num, P2ideNote::script(), self.into())
285 }
286
287 pub fn reclaimer(&self) -> AccountId {
289 self.reclaimer
290 }
291
292 pub fn target(&self) -> AccountId {
294 self.target
295 }
296
297 pub fn reclaim_height(&self) -> Option<BlockNumber> {
299 self.reclaim_height
300 }
301
302 pub fn timelock_height(&self) -> Option<BlockNumber> {
304 self.timelock_height
305 }
306}
307
308impl From<P2ideNoteStorage> for NoteStorage {
309 fn from(storage: P2ideNoteStorage) -> Self {
310 let reclaim = storage.reclaim_height.map_or(Felt::ZERO, Felt::from);
312 let timelock = storage.timelock_height.map_or(Felt::ZERO, Felt::from);
313
314 NoteStorage::new(vec![
316 storage.reclaimer.suffix(),
317 storage.reclaimer.prefix().as_felt(),
318 storage.target.suffix(),
319 storage.target.prefix().as_felt(),
320 reclaim,
321 timelock,
322 ])
323 .expect("number of storage items should not exceed max storage items")
324 }
325}
326
327impl TryFrom<&[Felt]> for P2ideNoteStorage {
328 type Error = NoteError;
329
330 fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
331 if note_storage.len() != P2ideNote::NUM_STORAGE_ITEMS {
332 return Err(NoteError::InvalidNoteStorageLength {
333 expected: P2ideNote::NUM_STORAGE_ITEMS,
334 actual: note_storage.len(),
335 });
336 }
337
338 let reclaimer = AccountId::try_from_elements(
339 note_storage[Self::RECLAIMER_SUFFIX_IDX],
340 note_storage[Self::RECLAIMER_PREFIX_IDX],
341 )
342 .map_err(|err| {
343 NoteError::other_with_source("failed to create reclaimer account id", err)
344 })?;
345
346 let target = AccountId::try_from_elements(
347 note_storage[Self::TARGET_SUFFIX_IDX],
348 note_storage[Self::TARGET_PREFIX_IDX],
349 )
350 .map_err(|err| NoteError::other_with_source("failed to create target account id", err))?;
351
352 let reclaim_height = decode_optional_block_height(
353 note_storage[Self::RECLAIM_HEIGHT_IDX],
354 "invalid reclaim height in note storage",
355 )?;
356 let timelock_height = decode_optional_block_height(
357 note_storage[Self::TIMELOCK_HEIGHT_IDX],
358 "invalid timelock height in note storage",
359 )?;
360
361 Ok(Self {
362 reclaimer,
363 target,
364 reclaim_height,
365 timelock_height,
366 })
367 }
368}
369
370impl NoteConsumptionCost for P2ideNote {
374 fn consumption_cycles() -> u32 {
375 P2IDE_CONSUMPTION_CYCLES
376 }
377}
378
379#[cfg(test)]
383mod tests {
384 use assert_matches::assert_matches;
385 use miden_protocol::account::{AccountId, AccountType};
386 use miden_protocol::asset::FungibleAsset;
387 use miden_protocol::block::BlockNumber;
388 use miden_protocol::crypto::rand::RandomCoin;
389 use miden_protocol::errors::NoteError;
390 use miden_protocol::{Felt, Word};
391
392 use super::*;
393
394 const INVALID_ID_SUFFIX: Felt = Felt::new_unchecked(999);
399 const INVALID_ID_PREFIX: Felt = Felt::new_unchecked(888);
400
401 fn dummy_account() -> AccountId {
402 AccountId::builder()
403 .account_type(AccountType::Private)
404 .build_with_seed([3u8; 32])
405 }
406
407 #[test]
411 fn try_from_valid_storage_with_all_fields_succeeds() {
412 let reclaimer = sender();
413 let target = dummy_account();
414
415 let storage = vec![
416 reclaimer.suffix(),
417 reclaimer.prefix().as_felt(),
418 target.suffix(),
419 target.prefix().as_felt(),
420 Felt::from(42u32),
421 Felt::from(100u32),
422 ];
423
424 let decoded = P2ideNoteStorage::try_from(storage.as_slice())
425 .expect("valid P2IDE storage should decode");
426
427 assert_eq!(decoded.reclaimer(), reclaimer);
428 assert_eq!(decoded.target(), target);
429 assert_eq!(decoded.reclaim_height(), Some(BlockNumber::from(42u32)));
430 assert_eq!(decoded.timelock_height(), Some(BlockNumber::from(100u32)));
431 }
432
433 #[test]
434 fn try_from_zero_heights_map_to_none() {
435 let reclaimer = sender();
436 let target = dummy_account();
437
438 let storage = vec![
439 reclaimer.suffix(),
440 reclaimer.prefix().as_felt(),
441 target.suffix(),
442 target.prefix().as_felt(),
443 Felt::ZERO,
444 Felt::ZERO,
445 ];
446
447 let decoded = P2ideNoteStorage::try_from(storage.as_slice()).unwrap();
448
449 assert_eq!(decoded.reclaim_height(), None);
450 assert_eq!(decoded.timelock_height(), None);
451 }
452
453 #[test]
454 fn try_from_invalid_length_fails() {
455 let storage = vec![Felt::ZERO; 3];
456
457 let err =
458 P2ideNoteStorage::try_from(storage.as_slice()).expect_err("wrong length must fail");
459
460 assert!(matches!(
461 err,
462 NoteError::InvalidNoteStorageLength {
463 expected: P2ideNote::NUM_STORAGE_ITEMS,
464 actual: 3
465 }
466 ));
467 }
468
469 #[test]
472 fn try_from_invalid_reclaimer_fails() {
473 let target = dummy_account();
474
475 let storage = vec![
476 INVALID_ID_SUFFIX,
477 INVALID_ID_PREFIX,
478 target.suffix(),
479 target.prefix().as_felt(),
480 Felt::ZERO,
481 Felt::ZERO,
482 ];
483
484 let err = P2ideNoteStorage::try_from(storage.as_slice())
485 .expect_err("invalid reclaimer encoding must fail");
486
487 assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
488 assert!(error_msg.contains("reclaimer"));
489 });
490 }
491
492 #[test]
493 fn try_from_invalid_target_fails() {
494 let reclaimer = sender();
495
496 let storage = vec![
497 reclaimer.suffix(),
498 reclaimer.prefix().as_felt(),
499 INVALID_ID_SUFFIX,
500 INVALID_ID_PREFIX,
501 Felt::ZERO,
502 Felt::ZERO,
503 ];
504
505 let err = P2ideNoteStorage::try_from(storage.as_slice())
506 .expect_err("invalid target encoding must fail");
507
508 assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
509 assert!(error_msg.contains("target"));
510 });
511 }
512
513 #[test]
521 fn storage_round_trips_through_note_storage() {
522 let storage = P2ideNoteStorage::new(
523 sender(),
524 target(),
525 Some(BlockNumber::from(42u32)),
526 Some(BlockNumber::from(100u32)),
527 );
528
529 let encoded: NoteStorage = storage.into();
530 let decoded = P2ideNoteStorage::try_from(encoded.items()).unwrap();
531
532 assert_eq!(decoded, storage);
533 }
534
535 #[test]
539 fn zero_reclaim_height_means_reclaim_disabled() {
540 let storage = P2ideNoteStorage::new(sender(), target(), Some(BlockNumber::GENESIS), None);
541
542 let encoded: NoteStorage = storage.into();
543 let decoded = P2ideNoteStorage::try_from(encoded.items()).unwrap();
544
545 assert_eq!(decoded.reclaim_height(), None);
546 }
547
548 #[test]
549 fn try_from_reclaim_height_overflow_fails() {
550 let reclaimer = sender();
551 let target = dummy_account();
552
553 let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 1);
555
556 let storage = vec![
557 reclaimer.suffix(),
558 reclaimer.prefix().as_felt(),
559 target.suffix(),
560 target.prefix().as_felt(),
561 overflow,
562 Felt::ZERO,
563 ];
564
565 let err = P2ideNoteStorage::try_from(storage.as_slice())
566 .expect_err("overflow reclaim height must fail");
567
568 assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
569 assert!(error_msg.contains("reclaim height"));
570 });
571 }
572
573 #[test]
574 fn try_from_timelock_height_overflow_fails() {
575 let reclaimer = sender();
576 let target = dummy_account();
577
578 let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 10);
579
580 let storage = vec![
581 reclaimer.suffix(),
582 reclaimer.prefix().as_felt(),
583 target.suffix(),
584 target.prefix().as_felt(),
585 Felt::ZERO,
586 overflow,
587 ];
588
589 let err = P2ideNoteStorage::try_from(storage.as_slice())
590 .expect_err("overflow timelock height must fail");
591
592 assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
593 assert!(error_msg.contains("timelock height"));
594 });
595 }
596
597 fn sender() -> AccountId {
601 AccountId::builder()
602 .account_type(AccountType::Private)
603 .build_with_seed([1u8; 32])
604 }
605
606 fn target() -> AccountId {
607 AccountId::builder()
608 .account_type(AccountType::Private)
609 .build_with_seed([2u8; 32])
610 }
611
612 fn faucet_a() -> AccountId {
613 AccountId::builder()
614 .account_type(AccountType::Public)
615 .build_with_seed([3u8; 32])
616 }
617
618 fn faucet_b() -> AccountId {
619 AccountId::builder()
620 .account_type(AccountType::Public)
621 .build_with_seed([4u8; 32])
622 }
623
624 #[test]
627 fn builder_minimal_uses_defaults() {
628 let note = P2ideNote::builder()
629 .sender(sender())
630 .target(target())
631 .serial_number(Word::empty())
632 .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
633 .build()
634 .unwrap();
635
636 assert_eq!(note.sender(), sender());
637 assert_eq!(note.target(), target());
638 assert_eq!(note.reclaimer(), sender());
640 assert_eq!(note.note_type(), NoteType::default());
641 assert_eq!(note.reclaim_height(), None);
642 assert_eq!(note.timelock_height(), None);
643 assert_eq!(note.assets().num_assets(), 1);
644 assert_eq!(note.attachments().num_attachments(), 0);
645 }
646
647 #[test]
649 fn builder_accumulates_assets() {
650 let mut rng = RandomCoin::new(Word::empty());
651 let note = P2ideNote::builder()
652 .sender(sender())
653 .target(target())
654 .asset(FungibleAsset::new(faucet_a(), 100).unwrap())
655 .assets([Asset::from(FungibleAsset::new(faucet_b(), 200).unwrap())])
656 .generate_serial_number(&mut rng)
657 .build()
658 .unwrap();
659
660 assert_eq!(note.assets().num_assets(), 2);
661 assert_ne!(note.serial_number(), Word::empty());
662 }
663
664 #[test]
666 fn builder_rejects_empty_assets() {
667 let err = P2ideNote::builder()
668 .sender(sender())
669 .target(target())
670 .serial_number(Word::empty())
671 .build()
672 .expect_err("a note without assets must be rejected");
673
674 assert_matches!(err, NoteError::Other { error_msg, .. } => {
675 assert!(error_msg.contains("note must contain at least one asset"))
676 });
677 }
678
679 #[test]
681 fn builder_sets_reclaim_and_timelock() {
682 let note = P2ideNote::builder()
683 .sender(sender())
684 .target(target())
685 .serial_number(Word::empty())
686 .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
687 .reclaim_height(BlockNumber::from(42u32))
688 .timelock_height(BlockNumber::from(100u32))
689 .build()
690 .unwrap();
691
692 assert_eq!(note.reclaim_height(), Some(BlockNumber::from(42u32)));
693 assert_eq!(note.timelock_height(), Some(BlockNumber::from(100u32)));
694 }
695
696 #[test]
699 fn builder_explicit_reclaimer_differs_from_sender() {
700 let note = P2ideNote::builder()
701 .sender(sender())
702 .target(target())
703 .reclaimer(dummy_account())
704 .serial_number(Word::empty())
705 .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
706 .build()
707 .unwrap();
708
709 assert_eq!(note.sender(), sender());
710 assert_eq!(note.reclaimer(), dummy_account());
711 assert_ne!(note.reclaimer(), note.sender());
712
713 let storage: NoteStorage = note.storage().into();
715 let decoded = P2ideNoteStorage::try_from(storage.items()).unwrap();
716 assert_eq!(decoded.reclaimer(), dummy_account());
717 assert_eq!(decoded.target(), target());
718 }
719}