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 NoteDetails,
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, ONE, Word};
24
25use crate::StandardsLib;
26use crate::note::costs::{NoteConsumptionCost, SWAP_CONSUMPTION_CYCLES};
27use crate::note::{P2idNote, P2idNoteStorage};
28
29const SWAP_SCRIPT_PATH: &str = "::miden::standards::notes::swap::main";
34
35static SWAP_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
37 let standards_lib = StandardsLib::default();
38 let path = Path::new(SWAP_SCRIPT_PATH);
39 NoteScript::from_package_reference(standards_lib.as_ref(), path)
40 .expect("Standards library contains SWAP note script procedure")
41});
42
43#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct SwapNote {
59 sender: AccountId,
60 offered_asset: Asset,
61 serial_number: Word,
62 note_type: NoteType,
63 storage: SwapNoteStorage,
64 attachments: NoteAttachments,
65}
66
67#[bon::bon]
68impl SwapNote {
69 #[builder]
80 pub fn new(
81 #[builder(field)] attachments: Vec<NoteAttachment>,
82 sender: AccountId,
83 #[builder(into)] offered_asset: Asset,
84 #[builder(into)] requested_asset: Asset,
85 serial_number: Word,
89 #[builder(default)]
93 note_type: NoteType,
94 #[builder(default)]
99 payback_note_type: NoteType,
100 ) -> Result<Self, NoteError> {
101 if requested_asset == offered_asset {
102 return Err(NoteError::other("requested asset same as offered asset"));
103 }
104
105 let attachments = NoteAttachments::new(attachments)?;
106
107 let payback_tag = NoteTag::with_account_target(sender);
108
109 let storage = match payback_note_type {
110 NoteType::Private => SwapNoteStorage::new_private(
111 requested_asset,
112 Self::payback_recipient(sender, serial_number).digest(),
113 payback_tag,
114 ),
115 NoteType::Public => SwapNoteStorage::new_public(requested_asset, sender, payback_tag),
116 };
117
118 Ok(Self {
119 sender,
120 offered_asset,
121 serial_number,
122 note_type,
123 storage,
124 attachments,
125 })
126 }
127}
128
129impl SwapNote {
130 pub const NUM_STORAGE_ITEMS: usize = SwapNoteStorage::NUM_ITEMS;
135
136 pub fn script() -> NoteScript {
141 SWAP_SCRIPT.clone()
142 }
143
144 pub fn script_root() -> NoteScriptRoot {
146 SWAP_SCRIPT.root()
147 }
148
149 pub fn sender(&self) -> AccountId {
151 self.sender
152 }
153
154 pub fn offered_asset(&self) -> Asset {
156 self.offered_asset
157 }
158
159 pub fn requested_asset(&self) -> Asset {
161 self.storage().requested_asset()
162 }
163
164 pub fn serial_number(&self) -> Word {
166 self.serial_number
167 }
168
169 pub fn note_type(&self) -> NoteType {
171 self.note_type
172 }
173
174 pub fn payback_note_type(&self) -> NoteType {
176 self.storage().payback_note_type()
177 }
178
179 pub fn attachments(&self) -> &NoteAttachments {
181 &self.attachments
182 }
183
184 pub fn storage(&self) -> &SwapNoteStorage {
186 &self.storage
187 }
188
189 pub fn payback_note_details(&self) -> NoteDetails {
192 let assets = NoteAssets::new(vec![self.requested_asset()])
193 .expect("a single asset never exceeds the note asset limit");
194
195 NoteDetails::new(assets, Self::payback_recipient(self.sender, self.serial_number))
196 }
197
198 pub fn create_tag(
214 note_type: NoteType,
215 offered_asset: &Asset,
216 requested_asset: &Asset,
217 ) -> NoteTag {
218 let swap_root_bytes = Self::script().root().as_bytes();
219 let mut swap_use_case_id = (swap_root_bytes[0] as u16) << 7;
222 swap_use_case_id |= (swap_root_bytes[1] >> 1) as u16;
223
224 let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into();
226 let offered_asset_tag = (offered_asset_id >> 56) as u8;
227
228 let requested_asset_id: u64 = requested_asset.faucet_id().prefix().into();
229 let requested_asset_tag = (requested_asset_id >> 56) as u8;
230
231 let asset_pair = ((offered_asset_tag as u16) << 8) | (requested_asset_tag as u16);
232
233 let tag = ((note_type as u8 as u32) << 31)
234 | ((swap_use_case_id as u32) << 16)
235 | asset_pair as u32;
236
237 NoteTag::new(tag)
238 }
239
240 fn payback_recipient(sender: AccountId, serial_number: Word) -> NoteRecipient {
245 P2idNoteStorage::new(sender).into_recipient(payback_serial_from_swap(serial_number))
246 }
247}
248
249impl<S: swap_note_builder::State> SwapNoteBuilder<S> {
253 pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
255 self.attachments.push(attachment.into());
256 self
257 }
258
259 pub fn attachments(
261 mut self,
262 attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
263 ) -> Self {
264 self.attachments.extend(attachments.into_iter().map(Into::into));
265 self
266 }
267}
268
269impl<S: swap_note_builder::State> SwapNoteBuilder<S>
270where
271 S::SerialNumber: swap_note_builder::IsUnset,
272{
273 pub fn generate_serial_number(
275 self,
276 rng: &mut impl FeltRng,
277 ) -> SwapNoteBuilder<swap_note_builder::SetSerialNumber<S>> {
278 self.serial_number(rng.draw_word())
279 }
280}
281
282impl From<SwapNote> for Note {
286 fn from(note: SwapNote) -> Self {
287 let SwapNote {
288 sender,
289 offered_asset,
290 serial_number,
291 note_type,
292 storage,
293 attachments,
294 } = note;
295
296 let tag = SwapNote::create_tag(note_type, &offered_asset, &storage.requested_asset());
297 let metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag);
298 let recipient = storage.into_recipient(serial_number);
299
300 let assets = NoteAssets::new(vec![offered_asset])
301 .expect("a single asset never exceeds the note asset limit");
302
303 Note::with_attachments(assets, metadata, recipient, attachments)
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct SwapNoteStorage {
326 requested_asset: Asset,
327 payback_tag: NoteTag,
328 payback: SwapPayback,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq)]
339pub enum SwapPayback {
340 Private {
341 recipient: Word,
343 },
344 Public {
345 payback_target_id: AccountId,
347 },
348}
349
350impl SwapNoteStorage {
351 pub const NUM_ITEMS: usize = 16;
356
357 pub fn new_private(
362 requested_asset: Asset,
363 payback_recipient: Word,
364 payback_tag: NoteTag,
365 ) -> Self {
366 Self {
367 requested_asset,
368 payback_tag,
369 payback: SwapPayback::Private { recipient: payback_recipient },
370 }
371 }
372
373 pub fn new_public(
375 requested_asset: Asset,
376 payback_target_id: AccountId,
377 payback_tag: NoteTag,
378 ) -> Self {
379 Self {
380 requested_asset,
381 payback_tag,
382 payback: SwapPayback::Public { payback_target_id },
383 }
384 }
385
386 pub fn payback_note_type(&self) -> NoteType {
391 match self.payback {
392 SwapPayback::Private { .. } => NoteType::Private,
393 SwapPayback::Public { .. } => NoteType::Public,
394 }
395 }
396
397 pub fn requested_asset(&self) -> Asset {
399 self.requested_asset
400 }
401
402 pub fn payback_tag(&self) -> NoteTag {
404 self.payback_tag
405 }
406
407 pub fn payback(&self) -> &SwapPayback {
409 &self.payback
410 }
411
412 pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
417 NoteRecipient::new(serial_num, SwapNote::script(), NoteStorage::from(self))
418 }
419}
420
421impl From<SwapNoteStorage> for NoteStorage {
422 fn from(storage: SwapNoteStorage) -> Self {
423 let mut storage_values = Vec::with_capacity(SwapNoteStorage::NUM_ITEMS);
424
425 storage_values.extend_from_slice(&storage.requested_asset.as_elements());
427
428 match storage.payback {
429 SwapPayback::Private { recipient } => {
430 storage_values.extend_from_slice(recipient.as_elements());
432 storage_values.push(Felt::from(NoteType::Private.as_u8()));
434 storage_values.push(Felt::from(storage.payback_tag.as_u32()));
436 storage_values.extend_from_slice(&[Felt::ZERO; 2]);
438 },
439 SwapPayback::Public { payback_target_id } => {
440 storage_values.extend_from_slice(&[Felt::ZERO; 4]);
442 storage_values.push(Felt::from(NoteType::Public.as_u8()));
444 storage_values.push(Felt::from(storage.payback_tag.as_u32()));
446 storage_values.push(payback_target_id.suffix());
448 storage_values.push(payback_target_id.prefix().as_felt());
449 },
450 }
451
452 NoteStorage::new(storage_values)
453 .expect("number of storage items should not exceed max storage items")
454 }
455}
456
457impl TryFrom<&[Felt]> for SwapNoteStorage {
459 type Error = NoteError;
460
461 fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
462 if note_storage.len() != Self::NUM_ITEMS {
463 return Err(NoteError::InvalidNoteStorageLength {
464 expected: Self::NUM_ITEMS,
465 actual: note_storage.len(),
466 });
467 }
468
469 let key = Word::new([note_storage[0], note_storage[1], note_storage[2], note_storage[3]]);
471 let value = Word::new([note_storage[4], note_storage[5], note_storage[6], note_storage[7]]);
472 let requested_asset = Asset::from_id_and_value_words(key, value)
473 .map_err(|err| NoteError::other_with_source("failed to parse requested asset", err))?;
474
475 let payback_note_type = NoteType::try_from(
477 u8::try_from(note_storage[12].as_canonical_u64())
478 .map_err(|_| NoteError::other("payback_note_type exceeds u8"))?,
479 )
480 .map_err(|err| NoteError::other_with_source("failed to parse payback note type", err))?;
481
482 let payback_tag_u32 = u32::try_from(note_storage[13].as_canonical_u64())
484 .map_err(|_| NoteError::other("SWAP payback_tag exceeds u32"))?;
485 let payback_tag = NoteTag::new(payback_tag_u32);
486
487 let payback = match payback_note_type {
488 NoteType::Private => {
489 if note_storage[14].as_canonical_u64() != 0
491 || note_storage[15].as_canonical_u64() != 0
492 {
493 return Err(NoteError::other(
494 "SWAP private payback must have payback target id slots cleared",
495 ));
496 }
497
498 let recipient = Word::new([
500 note_storage[8],
501 note_storage[9],
502 note_storage[10],
503 note_storage[11],
504 ]);
505
506 SwapPayback::Private { recipient }
507 },
508 NoteType::Public => {
509 if note_storage[8..=11].iter().any(|felt| felt.as_canonical_u64() != 0) {
511 return Err(NoteError::other(
512 "SWAP public payback must have recipient slots cleared",
513 ));
514 }
515
516 let payback_target_id = AccountId::try_from_elements(
517 note_storage[14],
518 note_storage[15],
519 )
520 .map_err(|err| {
521 NoteError::other_with_source("failed to parse payback target account ID", err)
522 })?;
523
524 SwapPayback::Public { payback_target_id }
525 },
526 };
527
528 Ok(Self { requested_asset, payback_tag, payback })
529 }
530}
531
532pub fn payback_serial_from_swap(swap_serial: Word) -> Word {
538 let elements = swap_serial.as_elements();
539 Word::new([elements[0] + ONE, elements[1], elements[2], elements[3]])
540}
541
542impl NoteConsumptionCost for SwapNote {
546 fn consumption_cycles() -> u32 {
547 SWAP_CONSUMPTION_CYCLES
548 }
549
550 fn created_notes() -> Vec<NoteScriptRoot> {
552 vec![P2idNote::script_root()]
553 }
554}
555
556#[cfg(test)]
560mod tests {
561
562 use assert_matches::assert_matches;
563 use miden_protocol::account::{AccountIdVersion, AccountType, AssetCallbackFlag};
564 use miden_protocol::asset::{FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails};
565 use miden_protocol::crypto::rand::RandomCoin;
566 use miden_protocol::note::{NoteStorage, NoteType};
567 use miden_protocol::testing::account_id::{
568 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
569 ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET,
570 };
571 use rstest::rstest;
572
573 use super::*;
574 use crate::note::{NetworkAccountTarget, P2idNote};
575
576 fn fungible_faucet() -> AccountId {
577 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap()
578 }
579
580 fn non_fungible_faucet() -> AccountId {
581 ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into().unwrap()
582 }
583
584 fn fungible_asset() -> Asset {
585 Asset::Fungible(FungibleAsset::new(fungible_faucet(), 1000).unwrap())
586 }
587
588 fn non_fungible_asset() -> Asset {
589 let details = NonFungibleAssetDetails::new(non_fungible_faucet(), vec![0xaa, 0xbb]);
590 Asset::NonFungible(NonFungibleAsset::new(&details))
591 }
592
593 fn dummy_target_id() -> AccountId {
594 AccountId::builder().account_type(AccountType::Private).build_with_seed([1; 32])
595 }
596
597 fn dummy_recipient_digest() -> Word {
598 Word::new([Felt::from(7u32), Felt::from(11u32), Felt::from(13u32), Felt::from(17u32)])
599 }
600
601 fn dummy_payback_tag() -> NoteTag {
602 NoteTag::new(0xabcd1234)
603 }
604
605 #[rstest]
609 #[case::private_payback(NoteType::Private)]
610 #[case::public_payback(NoteType::Public)]
611 fn swap_note_builder(#[case] payback_note_type: NoteType) -> anyhow::Result<()> {
612 let sender = dummy_target_id();
613 let attachment = NoteAttachment::with_word(
614 NetworkAccountTarget::ATTACHMENT_SCHEME,
615 dummy_recipient_digest(),
616 );
617 let swap_note_type = NoteType::Public;
618 let swap_note = SwapNote::builder()
619 .sender(sender)
620 .offered_asset(fungible_asset())
621 .requested_asset(non_fungible_asset())
622 .note_type(swap_note_type)
623 .payback_note_type(payback_note_type)
624 .attachment(attachment.clone())
625 .generate_serial_number(&mut RandomCoin::new(Word::from([1, 2, 3, 4u32])))
626 .build()?;
627
628 let serial_number = swap_note.serial_number();
629 let storage = swap_note.storage().clone();
630 let payback_note = swap_note.payback_note_details();
631 let note = Note::from(swap_note);
632
633 assert_eq!(note.metadata().sender(), sender);
634 assert_eq!(note.metadata().note_type(), swap_note_type);
635 assert_eq!(
636 note.metadata().tag(),
637 SwapNote::create_tag(swap_note_type, &fungible_asset(), &non_fungible_asset())
638 );
639 assert_eq!(note.assets().num_assets(), 1);
640 assert_eq!(note.assets().iter().next(), Some(&fungible_asset()));
641 assert_eq!(note.attachments().get(0), Some(&attachment));
642 assert_eq!(note.recipient().script().root(), SwapNote::script_root());
643 assert_eq!(
644 SwapNoteStorage::try_from(note.recipient().storage().items())?,
645 storage,
646 "the note's storage must match the one derived from the SWAP note"
647 );
648 assert_eq!(storage.payback_tag(), NoteTag::with_account_target(sender));
649
650 assert_eq!(payback_note.assets().iter().next(), Some(&non_fungible_asset()));
651 assert_eq!(payback_note.recipient().serial_num(), payback_serial_from_swap(serial_number));
652 assert_eq!(payback_note.recipient().script().root(), P2idNote::script_root());
653
654 match (payback_note_type, storage.payback()) {
657 (NoteType::Private, SwapPayback::Private { recipient }) => {
658 assert_eq!(*recipient, payback_note.recipient().digest());
659 },
660 (NoteType::Public, SwapPayback::Public { payback_target_id }) => {
661 assert_eq!(*payback_target_id, sender);
662 },
663 (note_type, payback) => panic!("payback {payback:?} does not match {note_type:?}"),
664 }
665
666 Ok(())
667 }
668
669 #[test]
670 fn swap_note_storage_round_trip_fungible_private() {
671 let storage = SwapNoteStorage::new_private(
672 fungible_asset(),
673 dummy_recipient_digest(),
674 dummy_payback_tag(),
675 );
676
677 let note_storage = NoteStorage::from(storage.clone());
678 assert_eq!(note_storage.num_items() as usize, SwapNoteStorage::NUM_ITEMS);
679 assert_eq!(storage.payback_note_type(), NoteType::Private);
680 assert_eq!(storage.requested_asset(), fungible_asset());
681 assert_eq!(storage.payback_tag(), dummy_payback_tag());
682 match storage.payback() {
683 SwapPayback::Private { recipient } => {
684 assert_eq!(*recipient, dummy_recipient_digest());
685 },
686 SwapPayback::Public { .. } => panic!("expected private payback"),
687 }
688
689 let parsed =
690 SwapNoteStorage::try_from(note_storage.items()).expect("round trip should succeed");
691 assert_eq!(parsed, storage);
692 }
693
694 #[test]
695 fn swap_note_storage_round_trip_non_fungible_public() {
696 let target = dummy_target_id();
697 let storage =
698 SwapNoteStorage::new_public(non_fungible_asset(), target, dummy_payback_tag());
699
700 let note_storage = NoteStorage::from(storage.clone());
701 assert_eq!(note_storage.num_items() as usize, SwapNoteStorage::NUM_ITEMS);
702 assert_eq!(storage.payback_note_type(), NoteType::Public);
703 assert_eq!(storage.requested_asset(), non_fungible_asset());
704 assert_eq!(storage.payback_tag(), dummy_payback_tag());
705 match storage.payback() {
706 SwapPayback::Public { payback_target_id } => {
707 assert_eq!(*payback_target_id, target);
708 },
709 SwapPayback::Private { .. } => panic!("expected public payback"),
710 }
711
712 let parsed =
713 SwapNoteStorage::try_from(note_storage.items()).expect("round trip should succeed");
714 assert_eq!(parsed, storage);
715 }
716
717 #[test]
718 fn swap_note_storage_private_rejects_dirty_target_slots() {
719 let mut items: Vec<Felt> = NoteStorage::from(SwapNoteStorage::new_private(
720 fungible_asset(),
721 dummy_recipient_digest(),
722 dummy_payback_tag(),
723 ))
724 .items()
725 .to_vec();
726
727 items[14] = Felt::from(1u32);
729 let err = SwapNoteStorage::try_from(items.as_slice())
730 .expect_err("private payback with a dirty target slot must be rejected");
731 assert_matches!(
732 err,
733 NoteError::Other { error_msg, .. }
734 if error_msg == "SWAP private payback must have payback target id slots cleared".into()
735 );
736 }
737
738 #[test]
739 fn swap_note_storage_public_rejects_dirty_private_slots() {
740 let mut items: Vec<Felt> = NoteStorage::from(SwapNoteStorage::new_public(
741 fungible_asset(),
742 dummy_target_id(),
743 dummy_payback_tag(),
744 ))
745 .items()
746 .to_vec();
747
748 items[8] = Felt::from(1u32);
750 let err = SwapNoteStorage::try_from(items.as_slice())
751 .expect_err("public payback with a dirty recipient slot must be rejected");
752 assert_matches!(
753 err,
754 NoteError::Other { error_msg, .. }
755 if error_msg == "SWAP public payback must have recipient slots cleared".into()
756 );
757 }
758
759 #[test]
760 fn swap_tag() {
761 let mut fungible_faucet_id_bytes = [0; 15];
763 fungible_faucet_id_bytes[0] = 0xcd;
764 fungible_faucet_id_bytes[1] = 0xb1;
765
766 let mut non_fungible_faucet_id_bytes = [0; 15];
768 non_fungible_faucet_id_bytes[0] = 0xab;
769 non_fungible_faucet_id_bytes[1] = 0xec;
770
771 let offered_asset = Asset::Fungible(
772 FungibleAsset::new(
773 AccountId::dummy(
774 fungible_faucet_id_bytes,
775 AccountIdVersion::Version1,
776 AccountType::Public,
777 AssetCallbackFlag::Disabled,
778 ),
779 2500,
780 )
781 .unwrap(),
782 );
783
784 let requested_asset =
785 Asset::NonFungible(NonFungibleAsset::new(&NonFungibleAssetDetails::new(
786 AccountId::dummy(
787 non_fungible_faucet_id_bytes,
788 AccountIdVersion::Version1,
789 AccountType::Public,
790 AssetCallbackFlag::Disabled,
791 ),
792 vec![0xaa, 0xbb, 0xcc, 0xdd],
793 )));
794
795 let expected_asset_pair = 0xcdab;
799
800 let note_type = NoteType::Public;
801 let actual_tag = SwapNote::create_tag(note_type, &offered_asset, &requested_asset);
802
803 assert_eq!(actual_tag.as_u32() as u16, expected_asset_pair, "asset pair should match");
804 assert_eq!((actual_tag.as_u32() >> 31) as u8, note_type as u8, "note type should match");
805 assert_eq!(
807 (actual_tag.as_u32() >> 23) as u8,
808 SwapNote::script_root().as_bytes()[0],
809 "swap script root byte 0 should match"
810 );
811 assert_eq!(
813 ((actual_tag.as_u32() & 0b00000000_01111111_00000000_00000000) >> 16) as u8,
814 SwapNote::script_root().as_bytes()[1] >> 1,
815 "swap script root byte 1 should match with the highest bit set to zero"
816 );
817 }
818}