1use alloc::vec;
2use alloc::vec::Vec;
3
4use miden_protocol::account::AccountId;
5use miden_protocol::assembly::Path;
6use miden_protocol::asset::{AssetAmount, FungibleAsset};
7use miden_protocol::errors::NoteError;
8use miden_protocol::note::{
9 Note,
10 NoteAssets,
11 NoteAttachment,
12 NoteAttachmentScheme,
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, ONE, Word, ZERO};
24
25use crate::StandardsLib;
26use crate::note::costs::{NoteConsumptionCost, PSWAP_CONSUMPTION_CYCLES};
27use crate::note::{P2idNote, P2idNoteStorage, StandardNoteAttachment};
28
29const PSWAP_SCRIPT_PATH: &str = "::miden::standards::notes::pswap::main";
34
35static PSWAP_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
37 let standards_lib = StandardsLib::default();
38 let path = Path::new(PSWAP_SCRIPT_PATH);
39 NoteScript::from_package_reference(standards_lib.as_ref(), path)
40 .expect("Standards library contains PSWAP note script procedure")
41});
42
43#[derive(Debug, Clone, PartialEq, Eq, bon::Builder)]
66pub struct PswapNoteStorage {
67 min_requested_asset: FungibleAsset,
68
69 creator_account_id: AccountId,
70
71 #[builder(default = NoteType::Private)]
77 payback_note_type: NoteType,
78
79 #[builder(default = AssetAmount::ZERO)]
92 min_fill_step: AssetAmount,
93}
94
95impl PswapNoteStorage {
96 pub const NUM_STORAGE_ITEMS: usize = 7;
101
102 pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
104 NoteRecipient::new(serial_num, PswapNote::script(), NoteStorage::from(self))
105 }
106
107 pub fn min_requested_asset(&self) -> &FungibleAsset {
112 &self.min_requested_asset
113 }
114
115 pub fn payback_note_tag(&self) -> NoteTag {
117 NoteTag::with_account_target(self.creator_account_id)
118 }
119
120 pub fn creator_account_id(&self) -> AccountId {
122 self.creator_account_id
123 }
124
125 pub fn payback_note_type(&self) -> NoteType {
127 self.payback_note_type
128 }
129
130 pub fn requested_faucet_id(&self) -> AccountId {
132 self.min_requested_asset.faucet_id()
133 }
134
135 pub fn min_requested_amount(&self) -> u64 {
137 self.min_requested_asset.amount().as_u64()
138 }
139
140 pub fn min_fill_step(&self) -> AssetAmount {
142 self.min_fill_step
143 }
144}
145
146impl From<PswapNoteStorage> for NoteStorage {
148 fn from(storage: PswapNoteStorage) -> Self {
149 let storage_items = vec![
150 storage.min_requested_asset.faucet_id().suffix(),
152 storage.min_requested_asset.faucet_id().prefix().as_felt(),
153 Felt::from(storage.min_requested_asset.amount()),
154 Felt::from(storage.min_fill_step),
156 Felt::from(storage.payback_note_type.as_u8()),
158 storage.creator_account_id.suffix(),
160 storage.creator_account_id.prefix().as_felt(),
161 ];
162 NoteStorage::new(storage_items)
163 .expect("number of storage items should not exceed max storage items")
164 }
165}
166
167impl TryFrom<&[Felt]> for PswapNoteStorage {
169 type Error = NoteError;
170
171 fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
172 if note_storage.len() != Self::NUM_STORAGE_ITEMS {
173 return Err(NoteError::InvalidNoteStorageLength {
174 expected: Self::NUM_STORAGE_ITEMS,
175 actual: note_storage.len(),
176 });
177 }
178
179 let faucet_id = AccountId::try_from_elements(note_storage[0], note_storage[1])
182 .map_err(|e| NoteError::other_with_source("failed to parse requested faucet ID", e))?;
183
184 let amount = note_storage[2].as_canonical_u64();
185 let min_requested_asset = FungibleAsset::new(faucet_id, amount)
186 .map_err(|e| NoteError::other_with_source("failed to create requested asset", e))?;
187
188 let min_fill_step = AssetAmount::new(note_storage[3].as_canonical_u64())
190 .map_err(|e| NoteError::other_with_source("failed to parse min_fill_step", e))?;
191
192 let payback_note_type = NoteType::try_from(
194 u8::try_from(note_storage[4].as_canonical_u64())
195 .map_err(|_| NoteError::other("payback_note_type exceeds u8"))?,
196 )
197 .map_err(|e| NoteError::other_with_source("failed to parse payback note type", e))?;
198
199 let creator_account_id = AccountId::try_from_elements(note_storage[5], note_storage[6])
201 .map_err(|e| NoteError::other_with_source("failed to parse creator account ID", e))?;
202
203 Ok(Self {
204 min_requested_asset,
205 creator_account_id,
206 payback_note_type,
207 min_fill_step,
208 })
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub struct PswapNoteAttachment {
219 amount: AssetAmount,
220 order_id: Felt,
221 depth: u32,
222}
223
224impl PswapNoteAttachment {
225 pub fn new(amount: AssetAmount, order_id: Felt, depth: u32) -> Self {
227 Self { amount, order_id, depth }
228 }
229
230 pub fn amount(&self) -> AssetAmount {
231 self.amount
232 }
233
234 pub fn order_id(&self) -> Felt {
235 self.order_id
236 }
237
238 pub fn depth(&self) -> u32 {
239 self.depth
240 }
241}
242
243impl From<PswapNoteAttachment> for NoteAttachment {
244 fn from(attachment: PswapNoteAttachment) -> Self {
245 let word = Word::from([
246 Felt::from(attachment.amount),
247 attachment.order_id,
248 Felt::from(attachment.depth),
249 ZERO,
250 ]);
251 NoteAttachment::with_word(PswapNote::PSWAP_ATTACHMENT_SCHEME, word)
252 }
253}
254
255impl TryFrom<&NoteAttachment> for PswapNoteAttachment {
258 type Error = NoteError;
259
260 fn try_from(attachment: &NoteAttachment) -> Result<Self, Self::Error> {
261 if attachment.attachment_scheme() != PswapNote::PSWAP_ATTACHMENT_SCHEME {
262 return Err(NoteError::other("attachment scheme is not the PSWAP attachment scheme"));
263 }
264
265 let [word] = attachment.content().as_words() else {
266 return Err(NoteError::other("PSWAP attachment must carry exactly one word"));
267 };
268
269 let amount = AssetAmount::new(word[0].as_canonical_u64())
270 .map_err(|e| NoteError::other_with_source("invalid PSWAP attachment amount", e))?;
271 let order_id = word[1];
272 let depth =
273 u32::try_from(word[PswapNote::PARENT_ATTACHMENT_DEPTH_OFFSET].as_canonical_u64())
274 .map_err(|_| NoteError::other("PSWAP depth does not fit in u32"))?;
275
276 if word[3] != ZERO {
277 return Err(NoteError::other("PSWAP attachment must be zero-padded"));
278 }
279
280 Ok(Self::new(amount, order_id, depth))
281 }
282}
283
284#[derive(Debug, Clone, bon::Builder)]
302#[builder(finish_fn(vis = "", name = build_internal))]
303pub struct PswapNote {
304 sender: AccountId,
305 storage: PswapNoteStorage,
306 serial_number: Word,
307
308 #[builder(default = NoteType::Private)]
309 note_type: NoteType,
310
311 offered_asset: FungibleAsset,
312
313 attachment: Option<NoteAttachment>,
314}
315
316impl<S: pswap_note_builder::State> PswapNoteBuilder<S>
317where
318 S: pswap_note_builder::IsComplete,
319{
320 pub fn build(self) -> Result<PswapNote, NoteError> {
327 let note = self.build_internal();
328
329 if note.offered_asset.faucet_id() == note.storage.requested_faucet_id() {
330 return Err(NoteError::other(
331 "offered and requested assets must have different faucets",
332 ));
333 }
334
335 if let Some(attachment) = note.attachment.as_ref()
336 && attachment.attachment_scheme() == PswapNote::PSWAP_ATTACHMENT_SCHEME
337 {
338 PswapNoteAttachment::try_from(attachment)?;
339 }
340
341 Ok(note)
342 }
343}
344
345impl PswapNote {
346 pub const NUM_STORAGE_ITEMS: usize = PswapNoteStorage::NUM_STORAGE_ITEMS;
351
352 pub const NUM_ASSETS: usize = 1;
356
357 pub const PSWAP_ATTACHMENT_SCHEME: NoteAttachmentScheme =
360 StandardNoteAttachment::PswapAttachment.attachment_scheme();
361
362 const PARENT_ATTACHMENT_DEPTH_OFFSET: usize = 2;
364
365 pub fn script() -> NoteScript {
370 PSWAP_SCRIPT.clone()
371 }
372
373 pub fn script_root() -> NoteScriptRoot {
375 PSWAP_SCRIPT.root()
376 }
377
378 pub fn create_args(account_fill: u64, note_fill: u64) -> Result<Word, NoteError> {
401 let account_fill = Felt::try_from(account_fill)
402 .map_err(|e| NoteError::other_with_source("account_fill is not a valid felt", e))?;
403 let note_fill = Felt::try_from(note_fill)
404 .map_err(|e| NoteError::other_with_source("note_fill is not a valid felt", e))?;
405 Ok(Word::from([account_fill, note_fill, ZERO, ZERO]))
406 }
407
408 pub fn sender(&self) -> AccountId {
410 self.sender
411 }
412
413 pub fn storage(&self) -> &PswapNoteStorage {
415 &self.storage
416 }
417
418 pub fn serial_number(&self) -> Word {
420 self.serial_number
421 }
422
423 pub fn note_type(&self) -> NoteType {
425 self.note_type
426 }
427
428 pub fn offered_asset(&self) -> &FungibleAsset {
430 &self.offered_asset
431 }
432
433 pub fn attachments(&self) -> Option<&NoteAttachment> {
441 self.attachment.as_ref()
442 }
443
444 pub fn order_id(&self) -> Felt {
446 self.serial_number[1]
447 }
448
449 pub fn parent_depth(&self) -> u32 {
456 self.attachment
457 .as_ref()
458 .and_then(|attachment| PswapNoteAttachment::try_from(attachment).ok())
459 .map_or(0, |attachment| attachment.depth())
460 }
461
462 pub fn execute_full_fill(&self, consumer_account_id: AccountId) -> Result<Note, NoteError> {
473 let requested_faucet_id = self.storage.requested_faucet_id();
474 let min_requested_amount = self.storage.min_requested_amount();
475
476 let fill_asset = FungibleAsset::new(requested_faucet_id, min_requested_amount)
477 .map_err(|e| NoteError::other_with_source("failed to create full fill asset", e))?;
478
479 self.create_payback_note(consumer_account_id, fill_asset, min_requested_amount)
480 }
481
482 pub fn execute(
498 &self,
499 consumer_account_id: AccountId,
500 account_fill_asset: Option<FungibleAsset>,
501 note_fill_asset: Option<FungibleAsset>,
502 ) -> Result<(Note, Option<PswapNote>), NoteError> {
503 let payback_asset = match (account_fill_asset, note_fill_asset) {
505 (Some(account_fill), Some(note_fill)) => account_fill.add(note_fill).map_err(|e| {
506 NoteError::other_with_source(
507 "failed to combine account fill and note fill assets",
508 e,
509 )
510 })?,
511 (Some(asset), None) | (None, Some(asset)) => asset,
512 (None, None) => {
513 return Err(NoteError::other(
514 "at least one of account_fill_asset or note_fill_asset must be provided",
515 ));
516 },
517 };
518 let fill_amount = payback_asset.amount().as_u64();
519
520 let total_offered_amount = self.offered_asset.amount().as_u64();
521 let requested_faucet_id = self.storage.requested_faucet_id();
522 let min_requested_amount = self.storage.min_requested_amount();
523
524 if fill_amount == 0 {
526 return Err(NoteError::other("Fill amount must be greater than 0"));
527 }
528
529 let account_fill_amount = account_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64());
530 let note_fill_amount = note_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64());
531
532 let effective_floor = self.storage.min_fill_step().as_u64().min(min_requested_amount);
537 if fill_amount < effective_floor {
538 return Err(NoteError::other("PSWAP fill amount is below the minimum fill step"));
539 }
540
541 let fill_reference = fill_amount.max(min_requested_amount);
547
548 let payout_for_account_fill = Self::calculate_output_amount(
552 total_offered_amount,
553 fill_reference,
554 account_fill_amount,
555 )?;
556 let payout_for_note_fill =
557 Self::calculate_output_amount(total_offered_amount, fill_reference, note_fill_amount)?;
558 let offered_amount_for_fill = payout_for_account_fill + payout_for_note_fill;
559
560 let payback_note =
561 self.create_payback_note(consumer_account_id, payback_asset, fill_amount)?;
562
563 let remainder = if fill_amount < min_requested_amount {
565 let remaining_offered = total_offered_amount - offered_amount_for_fill;
566 let remaining_requested = min_requested_amount - fill_amount;
567
568 let remaining_offered_asset =
569 FungibleAsset::new(self.offered_asset.faucet_id(), remaining_offered).map_err(
570 |e| NoteError::other_with_source("failed to create remainder asset", e),
571 )?;
572
573 let remaining_min_requested_asset =
574 FungibleAsset::new(requested_faucet_id, remaining_requested).map_err(|e| {
575 NoteError::other_with_source("failed to create remaining requested asset", e)
576 })?;
577
578 Some(self.create_remainder_pswap_note(
579 consumer_account_id,
580 remaining_offered_asset,
581 remaining_min_requested_asset,
582 offered_amount_for_fill,
583 )?)
584 } else {
585 None
586 };
587
588 Ok((payback_note, remainder))
589 }
590
591 pub fn calculate_offered_for_requested(&self, fill_amount: u64) -> Result<u64, NoteError> {
602 let min_requested = self.storage.min_requested_amount();
603 let total_offered = self.offered_asset.amount().as_u64();
604
605 let fill_reference = fill_amount.max(min_requested);
606 Self::calculate_output_amount(total_offered, fill_reference, fill_amount)
607 }
608
609 fn rounds_since(&self, attachment: &PswapNoteAttachment) -> Result<u32, NoteError> {
619 attachment
620 .depth()
621 .checked_sub(self.parent_depth())
622 .filter(|rounds| *rounds > 0)
623 .ok_or_else(|| {
624 NoteError::other("attachment depth must be greater than this note's depth")
625 })
626 }
627
628 pub fn payback_note(
644 &self,
645 consumer_account_id: AccountId,
646 attachment: &PswapNoteAttachment,
647 ) -> Result<Note, NoteError> {
648 let rounds = self.rounds_since(attachment)?;
651 let p2id_serial = Word::from([
652 self.serial_number[0] + ONE,
653 self.serial_number[1],
654 self.serial_number[2],
655 self.serial_number[3] + Felt::from(rounds - 1),
656 ]);
657
658 let recipient =
659 P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial);
660
661 let fill_asset =
662 FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(attachment.amount()))
663 .map_err(|e| NoteError::other_with_source("invalid fill amount", e))?;
664 let assets = NoteAssets::new(vec![fill_asset.into()])?;
665
666 let metadata =
667 PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type)
668 .with_tag(self.storage.payback_note_tag());
669
670 Ok(Note::with_attachments(
671 assets,
672 metadata,
673 recipient,
674 NoteAttachments::from(NoteAttachment::from(*attachment)),
675 ))
676 }
677
678 pub fn remainder_note(
698 &self,
699 consumer_account_id: AccountId,
700 attachment: &PswapNoteAttachment,
701 remaining_offered: AssetAmount,
702 remaining_requested: AssetAmount,
703 ) -> Result<Note, NoteError> {
704 let rounds = self.rounds_since(attachment)?;
706 let remainder_serial = Word::from([
707 self.serial_number[0],
708 self.serial_number[1],
709 self.serial_number[2],
710 self.serial_number[3] + Felt::from(rounds),
711 ]);
712
713 let min_requested_asset =
714 FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(remaining_requested))
715 .map_err(|e| {
716 NoteError::other_with_source("invalid remaining_requested amount", e)
717 })?;
718 let offered_asset =
719 FungibleAsset::new(self.offered_asset.faucet_id(), u64::from(remaining_offered))
720 .map_err(|e| NoteError::other_with_source("invalid remaining_offered amount", e))?;
721
722 let new_storage = PswapNoteStorage::builder()
723 .min_requested_asset(min_requested_asset)
724 .creator_account_id(self.storage.creator_account_id)
725 .payback_note_type(self.storage.payback_note_type)
726 .min_fill_step(self.storage.min_fill_step())
727 .build();
728 let recipient = new_storage.into_recipient(remainder_serial);
729
730 let assets = NoteAssets::new(vec![offered_asset.into()])?;
731
732 let tag = Self::create_tag(self.note_type, &offered_asset, &min_requested_asset);
733 let metadata = PartialNoteMetadata::new(consumer_account_id, self.note_type).with_tag(tag);
734
735 Ok(Note::with_attachments(
736 assets,
737 metadata,
738 recipient,
739 NoteAttachments::from(NoteAttachment::from(*attachment)),
740 ))
741 }
742
743 pub fn create_tag(
755 note_type: NoteType,
756 offered_asset: &FungibleAsset,
757 min_requested_asset: &FungibleAsset,
758 ) -> NoteTag {
759 let pswap_root_bytes = Self::script().root().as_bytes();
760
761 let mut pswap_use_case_id = (pswap_root_bytes[0] as u16) << 6;
764 pswap_use_case_id |= (pswap_root_bytes[1] >> 2) as u16;
765
766 let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into();
768 let offered_asset_tag = (offered_asset_id >> 56) as u8;
769
770 let min_requested_asset_id: u64 = min_requested_asset.faucet_id().prefix().into();
771 let min_requested_asset_tag = (min_requested_asset_id >> 56) as u8;
772
773 let asset_pair = ((offered_asset_tag as u16) << 8) | (min_requested_asset_tag as u16);
774
775 let tag = ((note_type as u8 as u32) << 30)
776 | ((pswap_use_case_id as u32) << 16)
777 | asset_pair as u32;
778
779 NoteTag::new(tag)
780 }
781
782 fn calculate_output_amount(
793 offered_total: u64,
794 fill_reference: u64,
795 fill_amount: u64,
796 ) -> Result<u64, NoteError> {
797 let product = (offered_total as u128) * (fill_amount as u128);
798 let quotient = product / (fill_reference as u128);
799 let amount = u64::try_from(quotient)
800 .map_err(|_| NoteError::other("payout quotient does not fit in u64"))?;
801 AssetAmount::new(amount).map_err(|e| {
803 NoteError::other_with_source("payout amount exceeds max fungible asset amount", e)
804 })?;
805 Ok(amount)
806 }
807
808 fn pswap_output_attachment(
814 amount: u64,
815 order_id: Felt,
816 depth: u64,
817 ) -> Result<NoteAttachment, NoteError> {
818 let amount = AssetAmount::new(amount)
819 .map_err(|e| NoteError::other_with_source("amount is not a valid asset amount", e))?;
820 let depth = u32::try_from(depth)
821 .map_err(|_| NoteError::other("PSWAP depth does not fit in u32"))?;
822 Ok(PswapNoteAttachment::new(amount, order_id, depth).into())
823 }
824
825 fn create_payback_note(
835 &self,
836 consumer_account_id: AccountId,
837 payback_asset: FungibleAsset,
838 fill_amount: u64,
839 ) -> Result<Note, NoteError> {
840 let payback_note_tag = self.storage.payback_note_tag();
841 let p2id_serial_num = Word::from([
843 self.serial_number[0] + ONE,
844 self.serial_number[1],
845 self.serial_number[2],
846 self.serial_number[3],
847 ]);
848
849 let recipient =
851 P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial_num);
852
853 let current_depth = u64::from(self.parent_depth()) + 1;
854 let attachment =
855 Self::pswap_output_attachment(fill_amount, self.order_id(), current_depth)?;
856
857 let p2id_assets = NoteAssets::new(vec![payback_asset.into()])?;
858 let p2id_metadata =
859 PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type)
860 .with_tag(payback_note_tag);
861
862 Ok(Note::with_attachments(
863 p2id_assets,
864 p2id_metadata,
865 recipient,
866 NoteAttachments::from(attachment),
867 ))
868 }
869
870 fn create_remainder_pswap_note(
880 &self,
881 consumer_account_id: AccountId,
882 remaining_offered_asset: FungibleAsset,
883 remaining_min_requested_asset: FungibleAsset,
884 offered_amount_for_fill: u64,
885 ) -> Result<PswapNote, NoteError> {
886 let new_storage = PswapNoteStorage::builder()
887 .min_requested_asset(remaining_min_requested_asset)
888 .creator_account_id(self.storage.creator_account_id)
889 .payback_note_type(self.storage.payback_note_type)
890 .min_fill_step(self.storage.min_fill_step())
891 .build();
892
893 let remainder_serial_num = Word::from([
896 self.serial_number[0],
897 self.serial_number[1],
898 self.serial_number[2],
899 self.serial_number[3] + ONE,
900 ]);
901
902 let current_depth = u64::from(self.parent_depth()) + 1;
903 let attachment =
904 Self::pswap_output_attachment(offered_amount_for_fill, self.order_id(), current_depth)?;
905
906 PswapNote::builder()
907 .sender(consumer_account_id)
908 .storage(new_storage)
909 .serial_number(remainder_serial_num)
910 .note_type(self.note_type)
911 .offered_asset(remaining_offered_asset)
912 .attachment(attachment)
913 .build()
914 }
915}
916
917impl From<PswapNote> for Note {
922 fn from(pswap: PswapNote) -> Self {
923 let tag = PswapNote::create_tag(
924 pswap.note_type,
925 &pswap.offered_asset,
926 pswap.storage.min_requested_asset(),
927 );
928
929 let recipient = pswap.storage.into_recipient(pswap.serial_number);
930
931 let assets = NoteAssets::new(vec![pswap.offered_asset.into()])
932 .expect("single fungible asset should be valid");
933
934 let metadata = PartialNoteMetadata::new(pswap.sender, pswap.note_type).with_tag(tag);
935
936 let attachments = pswap.attachment.map(NoteAttachments::from).unwrap_or_default();
937
938 Note::with_attachments(assets, metadata, recipient, attachments)
939 }
940}
941
942impl TryFrom<&Note> for PswapNote {
948 type Error = NoteError;
949
950 fn try_from(note: &Note) -> Result<Self, Self::Error> {
951 if note.recipient().script().root() != PswapNote::script_root() {
952 return Err(NoteError::other("note script root does not match PSWAP script root"));
953 }
954
955 let storage = PswapNoteStorage::try_from(note.recipient().storage().items())?;
956
957 if note.assets().num_assets() != Self::NUM_ASSETS {
958 return Err(NoteError::other("PSWAP note must have exactly one asset"));
959 }
960 let offered_asset = note
961 .assets()
962 .iter()
963 .next()
964 .expect("number of assets should have been validated")
965 .as_fungible()
966 .ok_or_else(|| NoteError::other("PSWAP note asset must be fungible"))?;
967
968 let attachment = match note.attachments().num_attachments() {
969 0 => None,
970 1 => {
971 Some(note.attachments().get(0).expect("length should have been validated").clone())
972 },
973 _ => return Err(NoteError::other("pswap note supports only one attachment")),
974 };
975
976 PswapNote::builder()
977 .sender(note.metadata().sender())
978 .storage(storage)
979 .serial_number(note.recipient().serial_num())
980 .note_type(note.metadata().note_type())
981 .offered_asset(offered_asset)
982 .maybe_attachment(attachment)
983 .build()
984 }
985}
986
987impl NoteConsumptionCost for PswapNote {
991 fn consumption_cycles() -> u32 {
992 PSWAP_CONSUMPTION_CYCLES
993 }
994
995 fn created_notes() -> Vec<NoteScriptRoot> {
998 vec![P2idNote::script_root(), PswapNote::script_root()]
999 }
1000}
1001
1002#[cfg(test)]
1006mod tests {
1007 use miden_protocol::account::{AccountId, AccountIdVersion, AccountType, AssetCallbackFlag};
1008 use miden_protocol::asset::FungibleAsset;
1009 use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
1010 use rstest::rstest;
1011
1012 use super::*;
1013
1014 fn dummy_faucet_id(byte: u8) -> AccountId {
1018 AccountId::builder()
1019 .account_type(AccountType::Public)
1020 .build_with_seed([byte; 32])
1021 }
1022
1023 fn dummy_creator_id() -> AccountId {
1024 AccountId::builder().account_type(AccountType::Public).build_with_seed([1; 32])
1025 }
1026
1027 fn dummy_consumer_id() -> AccountId {
1028 AccountId::builder().account_type(AccountType::Public).build_with_seed([2; 32])
1029 }
1030
1031 fn build_pswap_note(
1032 offered_asset: FungibleAsset,
1033 min_requested_asset: FungibleAsset,
1034 creator_id: AccountId,
1035 ) -> (PswapNote, Note) {
1036 let mut rng = RandomCoin::new(Word::default());
1037 let storage = PswapNoteStorage::builder()
1038 .min_requested_asset(min_requested_asset)
1039 .creator_account_id(creator_id)
1040 .build();
1041 let pswap = PswapNote::builder()
1042 .sender(creator_id)
1043 .storage(storage)
1044 .serial_number(rng.draw_word())
1045 .note_type(NoteType::Public)
1046 .offered_asset(offered_asset)
1047 .build()
1048 .unwrap();
1049 let note: Note = pswap.clone().into();
1050 (pswap, note)
1051 }
1052
1053 #[test]
1057 fn pswap_note_creation_and_script() {
1058 let creator_id = dummy_creator_id();
1059 let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap();
1060 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap();
1061
1062 let (pswap, note) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1063
1064 assert_eq!(pswap.sender(), creator_id);
1065 assert_eq!(pswap.note_type(), NoteType::Public);
1066
1067 let script = PswapNote::script();
1068 assert!(Word::from(script.root()) != Word::default(), "Script root should not be zero");
1069 assert_eq!(note.metadata().sender(), creator_id);
1070 assert_eq!(note.metadata().note_type(), NoteType::Public);
1071 assert_eq!(note.assets().num_assets(), 1);
1072 assert_eq!(note.recipient().script().root(), script.root());
1073 assert_eq!(
1074 note.recipient().storage().num_items(),
1075 PswapNoteStorage::NUM_STORAGE_ITEMS as u16,
1076 );
1077 }
1078
1079 #[test]
1080 fn pswap_note_builder() {
1081 let creator_id = dummy_creator_id();
1082 let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap();
1083 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap();
1084
1085 let (pswap, note) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1086
1087 assert_eq!(pswap.sender(), creator_id);
1088 assert_eq!(pswap.note_type(), NoteType::Public);
1089 assert_eq!(note.metadata().sender(), creator_id);
1090 assert_eq!(note.metadata().note_type(), NoteType::Public);
1091 assert_eq!(note.assets().num_assets(), 1);
1092 assert_eq!(
1093 note.recipient().storage().num_items(),
1094 PswapNoteStorage::NUM_STORAGE_ITEMS as u16,
1095 );
1096 }
1097
1098 #[test]
1099 fn pswap_tag() {
1100 let mut offered_faucet_bytes = [0; 15];
1101 offered_faucet_bytes[0] = 0xcd;
1102 offered_faucet_bytes[1] = 0xb1;
1103
1104 let mut requested_faucet_bytes = [0; 15];
1105 requested_faucet_bytes[0] = 0xab;
1106 requested_faucet_bytes[1] = 0xec;
1107
1108 let offered_asset = FungibleAsset::new(
1109 AccountId::dummy(
1110 offered_faucet_bytes,
1111 AccountIdVersion::Version1,
1112 AccountType::Public,
1113 AssetCallbackFlag::Disabled,
1114 ),
1115 100,
1116 )
1117 .unwrap();
1118 let min_requested_asset = FungibleAsset::new(
1119 AccountId::dummy(
1120 requested_faucet_bytes,
1121 AccountIdVersion::Version1,
1122 AccountType::Public,
1123 AssetCallbackFlag::Disabled,
1124 ),
1125 200,
1126 )
1127 .unwrap();
1128
1129 let tag = PswapNote::create_tag(NoteType::Public, &offered_asset, &min_requested_asset);
1130 let tag_u32 = u32::from(tag);
1131
1132 let note_type_bits = tag_u32 >> 30;
1134 assert_eq!(note_type_bits, NoteType::Public as u32);
1135 }
1136
1137 #[test]
1138 fn calculate_output_amount() {
1139 assert_eq!(PswapNote::calculate_output_amount(100, 100, 50).unwrap(), 50); assert_eq!(PswapNote::calculate_output_amount(200, 100, 50).unwrap(), 100); assert_eq!(PswapNote::calculate_output_amount(100, 200, 50).unwrap(), 25); let result = PswapNote::calculate_output_amount(100, 73, 7).unwrap();
1145 assert!(result > 0, "Should produce non-zero output");
1146 }
1147
1148 #[test]
1149 fn pswap_note_storage_try_from() {
1150 let creator_id = dummy_creator_id();
1151 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1152
1153 let storage_items = vec![
1156 min_requested_asset.faucet_id().suffix(),
1157 min_requested_asset.faucet_id().prefix().as_felt(),
1158 Felt::from(min_requested_asset.amount()),
1159 Felt::try_from(100u64).unwrap(), Felt::from(NoteType::Private.as_u8()), creator_id.suffix(),
1162 creator_id.prefix().as_felt(),
1163 ];
1164
1165 let parsed = PswapNoteStorage::try_from(storage_items.as_slice()).unwrap();
1166 assert_eq!(parsed.creator_account_id(), creator_id);
1167 assert_eq!(parsed.min_requested_amount(), 500);
1168 assert_eq!(parsed.min_fill_step().as_u64(), 100);
1169 }
1170
1171 #[test]
1172 fn pswap_note_storage_roundtrip() {
1173 let creator_id = dummy_creator_id();
1174 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1175
1176 let storage = PswapNoteStorage::builder()
1177 .min_requested_asset(min_requested_asset)
1178 .creator_account_id(creator_id)
1179 .min_fill_step(AssetAmount::new(42).unwrap())
1180 .build();
1181
1182 let note_storage = NoteStorage::from(storage.clone());
1183 assert_eq!(note_storage.num_items(), PswapNoteStorage::NUM_STORAGE_ITEMS as u16);
1184
1185 let parsed = PswapNoteStorage::try_from(note_storage.items()).unwrap();
1186
1187 assert_eq!(parsed.creator_account_id(), creator_id);
1188 assert_eq!(parsed.min_requested_amount(), 500);
1189 assert_eq!(parsed.min_fill_step().as_u64(), 42);
1190 }
1191
1192 #[test]
1193 fn pswap_note_storage_defaults_min_fill_step_to_zero() {
1194 let creator_id = dummy_creator_id();
1195 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1196
1197 let storage = PswapNoteStorage::builder()
1198 .min_requested_asset(min_requested_asset)
1199 .creator_account_id(creator_id)
1200 .build();
1201
1202 assert_eq!(
1203 storage.min_fill_step(),
1204 AssetAmount::ZERO,
1205 "min_fill_step must default to zero (no floor)",
1206 );
1207 }
1208
1209 #[rstest]
1214 #[case::below_floor(100, 30, 29, 0, false)]
1216 #[case::equal_floor(100, 30, 30, 0, true)]
1217 #[case::above_floor(100, 30, 50, 0, true)]
1218 #[case::clamped_full_fill(20, 50, 20, 0, true)]
1221 #[case::clamped_below_both(20, 50, 10, 0, false)]
1222 #[case::two_legs_meet_floor(100, 30, 20, 20, true)]
1224 #[case::two_legs_below_floor(100, 30, 10, 10, false)]
1225 fn pswap_execute_enforces_min_fill_step(
1226 #[case] min_requested: u64,
1227 #[case] min_fill_step: u64,
1228 #[case] account_fill: u64,
1229 #[case] note_fill: u64,
1230 #[case] expect_ok: bool,
1231 ) {
1232 let creator_id = dummy_creator_id();
1233 let consumer_id = dummy_consumer_id();
1234 let offered_faucet = dummy_faucet_id(0xaa);
1235 let requested_faucet = dummy_faucet_id(0xbb);
1236
1237 let offered_asset = FungibleAsset::new(offered_faucet, 200).unwrap();
1238 let min_requested_asset = FungibleAsset::new(requested_faucet, min_requested).unwrap();
1239 let storage = PswapNoteStorage::builder()
1240 .min_requested_asset(min_requested_asset)
1241 .creator_account_id(creator_id)
1242 .min_fill_step(AssetAmount::new(min_fill_step).unwrap())
1243 .build();
1244 let mut rng = RandomCoin::new(Word::default());
1245 let pswap = PswapNote::builder()
1246 .sender(creator_id)
1247 .storage(storage)
1248 .serial_number(rng.draw_word())
1249 .note_type(NoteType::Public)
1250 .offered_asset(offered_asset)
1251 .build()
1252 .unwrap();
1253
1254 let leg = |amt: u64| (amt > 0).then(|| FungibleAsset::new(requested_faucet, amt).unwrap());
1255 let result = pswap.execute(consumer_id, leg(account_fill), leg(note_fill));
1256
1257 assert_eq!(result.is_ok(), expect_ok, "unexpected accept/reject for this fill");
1258
1259 if let Ok((_, remainder)) = result {
1260 if account_fill + note_fill < min_requested {
1263 let rem = remainder.expect("partial fill should produce a remainder");
1264 assert_eq!(
1265 rem.storage().min_fill_step().as_u64(),
1266 min_fill_step,
1267 "remainder must inherit min_fill_step",
1268 );
1269 } else {
1270 assert!(remainder.is_none(), "full fill must complete the swap with no remainder");
1271 }
1272 }
1273 }
1274
1275 #[test]
1280 fn pswap_execute_combined_account_fill_and_note_fill_partial_fill() {
1281 let creator_id = dummy_creator_id();
1282 let consumer_id = dummy_consumer_id();
1283 let offered_faucet = dummy_faucet_id(0xaa);
1284 let requested_faucet = dummy_faucet_id(0xbb);
1285
1286 let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1288 let min_requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1289 let (pswap, _) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1290
1291 let account_fill = FungibleAsset::new(requested_faucet, 10).unwrap();
1293 let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1294
1295 let (payback, remainder) =
1296 pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap();
1297
1298 assert_eq!(payback.assets().num_assets(), 1);
1300 let payback_asset = payback.assets().iter().next().unwrap();
1301 let fa = payback_asset.unwrap_fungible();
1302 assert_eq!(fa.faucet_id(), requested_faucet);
1303 assert_eq!(fa.amount().as_u64(), 30);
1304
1305 let remainder = remainder.expect("partial fill should produce remainder");
1308 assert_eq!(remainder.storage().min_requested_amount(), 20);
1309 assert_eq!(remainder.offered_asset().amount().as_u64(), 40);
1310 assert_eq!(remainder.storage().creator_account_id(), creator_id);
1311 }
1312
1313 #[test]
1317 fn pswap_execute_combined_account_fill_and_note_fill_full_fill() {
1318 let creator_id = dummy_creator_id();
1319 let consumer_id = dummy_consumer_id();
1320 let offered_faucet = dummy_faucet_id(0xaa);
1321 let requested_faucet = dummy_faucet_id(0xbb);
1322
1323 let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1324 let min_requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1325 let (pswap, _) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1326
1327 let account_fill = FungibleAsset::new(requested_faucet, 30).unwrap();
1329 let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1330
1331 let (payback, remainder) =
1332 pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap();
1333
1334 assert_eq!(payback.assets().num_assets(), 1);
1336 let payback_asset = payback.assets().iter().next().unwrap();
1337 let fa = payback_asset.unwrap_fungible();
1338 assert_eq!(fa.faucet_id(), requested_faucet);
1339 assert_eq!(fa.amount().as_u64(), 50);
1340
1341 assert!(remainder.is_none(), "full fill must not produce a remainder");
1343 }
1344
1345 #[rstest]
1349 #[case::above_u32(Felt::new_unchecked(u64::from(u32::MAX) + 1))]
1350 #[case::wraps_the_field(Felt::MAX)]
1351 fn pswap_rejects_out_of_range_attachment_depth(#[case] depth: Felt) {
1352 let creator_id = dummy_creator_id();
1353 let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 100).unwrap();
1354 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 50).unwrap();
1355
1356 let storage = PswapNoteStorage::builder()
1357 .min_requested_asset(min_requested_asset)
1358 .creator_account_id(creator_id)
1359 .build();
1360 let attachment = NoteAttachment::with_word(
1361 PswapNote::PSWAP_ATTACHMENT_SCHEME,
1362 Word::from([ONE, ONE, depth, ZERO]),
1363 );
1364
1365 let result = PswapNote::builder()
1366 .sender(creator_id)
1367 .storage(storage)
1368 .serial_number(RandomCoin::new(Word::default()).draw_word())
1369 .note_type(NoteType::Public)
1370 .offered_asset(offered_asset)
1371 .attachment(attachment)
1372 .build();
1373
1374 assert!(result.is_err(), "an out-of-range depth must not build a PswapNote");
1375 }
1376
1377 #[test]
1381 fn pswap_lineage_helpers_are_relative_to_the_parent_depth() {
1382 let creator_id = dummy_creator_id();
1383 let consumer_id = dummy_consumer_id();
1384 let offered_faucet = dummy_faucet_id(0xaa);
1385 let requested_faucet = dummy_faucet_id(0xbb);
1386
1387 let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1388 let min_requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1389 let (original, _) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1390
1391 let fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1393 let (_, remainder) = original.execute(consumer_id, Some(fill), None).unwrap();
1394 let remainder = remainder.expect("partial fill should produce a remainder");
1395 assert_eq!(remainder.parent_depth(), 1);
1396
1397 let (round_two_payback, _) = remainder.execute(consumer_id, Some(fill), None).unwrap();
1398 let round_one_attachment = PswapNoteAttachment::try_from(
1399 remainder.attachments().expect("remainder carries an attachment"),
1400 )
1401 .unwrap();
1402 let round_two_attachment = PswapNoteAttachment::new(
1403 AssetAmount::new(20).unwrap(),
1404 round_one_attachment.order_id(),
1405 2,
1406 );
1407
1408 assert_eq!(
1409 original.payback_note(consumer_id, &round_two_attachment).unwrap().id(),
1410 round_two_payback.id(),
1411 "the original must reconstruct round 2 from its absolute depth",
1412 );
1413 assert_eq!(
1414 remainder.payback_note(consumer_id, &round_two_attachment).unwrap().id(),
1415 round_two_payback.id(),
1416 "the round's own parent must reconstruct it as well",
1417 );
1418 assert!(
1419 remainder.payback_note(consumer_id, &round_one_attachment).is_err(),
1420 "an attachment from the parent's own round is not a later round",
1421 );
1422 }
1423}