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 PSWAP_ATTACHMENT_SCHEME: NoteAttachmentScheme =
355 StandardNoteAttachment::PswapAttachment.attachment_scheme();
356
357 const PARENT_ATTACHMENT_DEPTH_OFFSET: usize = 2;
359
360 pub fn script() -> NoteScript {
365 PSWAP_SCRIPT.clone()
366 }
367
368 pub fn script_root() -> NoteScriptRoot {
370 PSWAP_SCRIPT.root()
371 }
372
373 pub fn create_args(account_fill: u64, note_fill: u64) -> Result<Word, NoteError> {
396 let account_fill = Felt::try_from(account_fill)
397 .map_err(|e| NoteError::other_with_source("account_fill is not a valid felt", e))?;
398 let note_fill = Felt::try_from(note_fill)
399 .map_err(|e| NoteError::other_with_source("note_fill is not a valid felt", e))?;
400 Ok(Word::from([account_fill, note_fill, ZERO, ZERO]))
401 }
402
403 pub fn sender(&self) -> AccountId {
405 self.sender
406 }
407
408 pub fn storage(&self) -> &PswapNoteStorage {
410 &self.storage
411 }
412
413 pub fn serial_number(&self) -> Word {
415 self.serial_number
416 }
417
418 pub fn note_type(&self) -> NoteType {
420 self.note_type
421 }
422
423 pub fn offered_asset(&self) -> &FungibleAsset {
425 &self.offered_asset
426 }
427
428 pub fn attachments(&self) -> Option<&NoteAttachment> {
436 self.attachment.as_ref()
437 }
438
439 pub fn order_id(&self) -> Felt {
441 self.serial_number[1]
442 }
443
444 pub fn parent_depth(&self) -> u32 {
451 self.attachment
452 .as_ref()
453 .and_then(|attachment| PswapNoteAttachment::try_from(attachment).ok())
454 .map_or(0, |attachment| attachment.depth())
455 }
456
457 pub fn execute_full_fill(&self, consumer_account_id: AccountId) -> Result<Note, NoteError> {
468 let requested_faucet_id = self.storage.requested_faucet_id();
469 let min_requested_amount = self.storage.min_requested_amount();
470
471 let fill_asset = FungibleAsset::new(requested_faucet_id, min_requested_amount)
472 .map_err(|e| NoteError::other_with_source("failed to create full fill asset", e))?;
473
474 self.create_payback_note(consumer_account_id, fill_asset, min_requested_amount)
475 }
476
477 pub fn execute(
493 &self,
494 consumer_account_id: AccountId,
495 account_fill_asset: Option<FungibleAsset>,
496 note_fill_asset: Option<FungibleAsset>,
497 ) -> Result<(Note, Option<PswapNote>), NoteError> {
498 let payback_asset = match (account_fill_asset, note_fill_asset) {
500 (Some(account_fill), Some(note_fill)) => account_fill.add(note_fill).map_err(|e| {
501 NoteError::other_with_source(
502 "failed to combine account fill and note fill assets",
503 e,
504 )
505 })?,
506 (Some(asset), None) | (None, Some(asset)) => asset,
507 (None, None) => {
508 return Err(NoteError::other(
509 "at least one of account_fill_asset or note_fill_asset must be provided",
510 ));
511 },
512 };
513 let fill_amount = payback_asset.amount().as_u64();
514
515 let total_offered_amount = self.offered_asset.amount().as_u64();
516 let requested_faucet_id = self.storage.requested_faucet_id();
517 let min_requested_amount = self.storage.min_requested_amount();
518
519 if fill_amount == 0 {
521 return Err(NoteError::other("Fill amount must be greater than 0"));
522 }
523
524 let account_fill_amount = account_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64());
525 let note_fill_amount = note_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64());
526
527 let effective_floor = self.storage.min_fill_step().as_u64().min(min_requested_amount);
532 if fill_amount < effective_floor {
533 return Err(NoteError::other("PSWAP fill amount is below the minimum fill step"));
534 }
535
536 let fill_reference = fill_amount.max(min_requested_amount);
542
543 let payout_for_account_fill = Self::calculate_output_amount(
547 total_offered_amount,
548 fill_reference,
549 account_fill_amount,
550 )?;
551 let payout_for_note_fill =
552 Self::calculate_output_amount(total_offered_amount, fill_reference, note_fill_amount)?;
553 let offered_amount_for_fill = payout_for_account_fill + payout_for_note_fill;
554
555 let payback_note =
556 self.create_payback_note(consumer_account_id, payback_asset, fill_amount)?;
557
558 let remainder = if fill_amount < min_requested_amount {
560 let remaining_offered = total_offered_amount - offered_amount_for_fill;
561 let remaining_requested = min_requested_amount - fill_amount;
562
563 let remaining_offered_asset =
564 FungibleAsset::new(self.offered_asset.faucet_id(), remaining_offered).map_err(
565 |e| NoteError::other_with_source("failed to create remainder asset", e),
566 )?;
567
568 let remaining_min_requested_asset =
569 FungibleAsset::new(requested_faucet_id, remaining_requested).map_err(|e| {
570 NoteError::other_with_source("failed to create remaining requested asset", e)
571 })?;
572
573 Some(self.create_remainder_pswap_note(
574 consumer_account_id,
575 remaining_offered_asset,
576 remaining_min_requested_asset,
577 offered_amount_for_fill,
578 )?)
579 } else {
580 None
581 };
582
583 Ok((payback_note, remainder))
584 }
585
586 pub fn calculate_offered_for_requested(&self, fill_amount: u64) -> Result<u64, NoteError> {
597 let min_requested = self.storage.min_requested_amount();
598 let total_offered = self.offered_asset.amount().as_u64();
599
600 let fill_reference = fill_amount.max(min_requested);
601 Self::calculate_output_amount(total_offered, fill_reference, fill_amount)
602 }
603
604 fn rounds_since(&self, attachment: &PswapNoteAttachment) -> Result<u32, NoteError> {
614 attachment
615 .depth()
616 .checked_sub(self.parent_depth())
617 .filter(|rounds| *rounds > 0)
618 .ok_or_else(|| {
619 NoteError::other("attachment depth must be greater than this note's depth")
620 })
621 }
622
623 pub fn payback_note(
635 &self,
636 consumer_account_id: AccountId,
637 attachment: &PswapNoteAttachment,
638 ) -> Result<Note, NoteError> {
639 let rounds = self.rounds_since(attachment)?;
642 let p2id_serial = Word::from([
643 self.serial_number[0] + ONE,
644 self.serial_number[1],
645 self.serial_number[2],
646 self.serial_number[3] + Felt::from(rounds - 1),
647 ]);
648
649 let recipient =
650 P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial);
651
652 let fill_asset =
653 FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(attachment.amount()))
654 .map_err(|e| NoteError::other_with_source("invalid fill amount", e))?;
655 let assets = NoteAssets::new(vec![fill_asset.into()])?;
656
657 let metadata =
658 PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type)
659 .with_tag(self.storage.payback_note_tag());
660
661 Ok(Note::with_attachments(
662 assets,
663 metadata,
664 recipient,
665 NoteAttachments::from(NoteAttachment::from(*attachment)),
666 ))
667 }
668
669 pub fn remainder_note(
687 &self,
688 consumer_account_id: AccountId,
689 attachment: &PswapNoteAttachment,
690 remaining_offered: AssetAmount,
691 remaining_requested: AssetAmount,
692 ) -> Result<Note, NoteError> {
693 let rounds = self.rounds_since(attachment)?;
695 let remainder_serial = Word::from([
696 self.serial_number[0],
697 self.serial_number[1],
698 self.serial_number[2],
699 self.serial_number[3] + Felt::from(rounds),
700 ]);
701
702 let min_requested_asset =
703 FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(remaining_requested))
704 .map_err(|e| {
705 NoteError::other_with_source("invalid remaining_requested amount", e)
706 })?;
707 let offered_asset =
708 FungibleAsset::new(self.offered_asset.faucet_id(), u64::from(remaining_offered))
709 .map_err(|e| NoteError::other_with_source("invalid remaining_offered amount", e))?;
710
711 let new_storage = PswapNoteStorage::builder()
712 .min_requested_asset(min_requested_asset)
713 .creator_account_id(self.storage.creator_account_id)
714 .payback_note_type(self.storage.payback_note_type)
715 .min_fill_step(self.storage.min_fill_step())
716 .build();
717 let recipient = new_storage.into_recipient(remainder_serial);
718
719 let assets = NoteAssets::new(vec![offered_asset.into()])?;
720
721 let tag = Self::create_tag(self.note_type, &offered_asset, &min_requested_asset);
722 let metadata = PartialNoteMetadata::new(consumer_account_id, self.note_type).with_tag(tag);
723
724 Ok(Note::with_attachments(
725 assets,
726 metadata,
727 recipient,
728 NoteAttachments::from(NoteAttachment::from(*attachment)),
729 ))
730 }
731
732 pub fn create_tag(
744 note_type: NoteType,
745 offered_asset: &FungibleAsset,
746 min_requested_asset: &FungibleAsset,
747 ) -> NoteTag {
748 let pswap_root_bytes = Self::script().root().as_bytes();
749
750 let mut pswap_use_case_id = (pswap_root_bytes[0] as u16) << 6;
753 pswap_use_case_id |= (pswap_root_bytes[1] >> 2) as u16;
754
755 let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into();
757 let offered_asset_tag = (offered_asset_id >> 56) as u8;
758
759 let min_requested_asset_id: u64 = min_requested_asset.faucet_id().prefix().into();
760 let min_requested_asset_tag = (min_requested_asset_id >> 56) as u8;
761
762 let asset_pair = ((offered_asset_tag as u16) << 8) | (min_requested_asset_tag as u16);
763
764 let tag = ((note_type as u8 as u32) << 30)
765 | ((pswap_use_case_id as u32) << 16)
766 | asset_pair as u32;
767
768 NoteTag::new(tag)
769 }
770
771 fn calculate_output_amount(
782 offered_total: u64,
783 fill_reference: u64,
784 fill_amount: u64,
785 ) -> Result<u64, NoteError> {
786 let product = (offered_total as u128) * (fill_amount as u128);
787 let quotient = product / (fill_reference as u128);
788 let amount = u64::try_from(quotient)
789 .map_err(|_| NoteError::other("payout quotient does not fit in u64"))?;
790 AssetAmount::new(amount).map_err(|e| {
792 NoteError::other_with_source("payout amount exceeds max fungible asset amount", e)
793 })?;
794 Ok(amount)
795 }
796
797 fn pswap_output_attachment(
803 amount: u64,
804 order_id: Felt,
805 depth: u64,
806 ) -> Result<NoteAttachment, NoteError> {
807 let amount = AssetAmount::new(amount)
808 .map_err(|e| NoteError::other_with_source("amount is not a valid asset amount", e))?;
809 let depth = u32::try_from(depth)
810 .map_err(|_| NoteError::other("PSWAP depth does not fit in u32"))?;
811 Ok(PswapNoteAttachment::new(amount, order_id, depth).into())
812 }
813
814 fn create_payback_note(
824 &self,
825 consumer_account_id: AccountId,
826 payback_asset: FungibleAsset,
827 fill_amount: u64,
828 ) -> Result<Note, NoteError> {
829 let payback_note_tag = self.storage.payback_note_tag();
830 let p2id_serial_num = Word::from([
832 self.serial_number[0] + ONE,
833 self.serial_number[1],
834 self.serial_number[2],
835 self.serial_number[3],
836 ]);
837
838 let recipient =
840 P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial_num);
841
842 let current_depth = u64::from(self.parent_depth()) + 1;
843 let attachment =
844 Self::pswap_output_attachment(fill_amount, self.order_id(), current_depth)?;
845
846 let p2id_assets = NoteAssets::new(vec![payback_asset.into()])?;
847 let p2id_metadata =
848 PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type)
849 .with_tag(payback_note_tag);
850
851 Ok(Note::with_attachments(
852 p2id_assets,
853 p2id_metadata,
854 recipient,
855 NoteAttachments::from(attachment),
856 ))
857 }
858
859 fn create_remainder_pswap_note(
869 &self,
870 consumer_account_id: AccountId,
871 remaining_offered_asset: FungibleAsset,
872 remaining_min_requested_asset: FungibleAsset,
873 offered_amount_for_fill: u64,
874 ) -> Result<PswapNote, NoteError> {
875 let new_storage = PswapNoteStorage::builder()
876 .min_requested_asset(remaining_min_requested_asset)
877 .creator_account_id(self.storage.creator_account_id)
878 .payback_note_type(self.storage.payback_note_type)
879 .min_fill_step(self.storage.min_fill_step())
880 .build();
881
882 let remainder_serial_num = Word::from([
885 self.serial_number[0],
886 self.serial_number[1],
887 self.serial_number[2],
888 self.serial_number[3] + ONE,
889 ]);
890
891 let current_depth = u64::from(self.parent_depth()) + 1;
892 let attachment =
893 Self::pswap_output_attachment(offered_amount_for_fill, self.order_id(), current_depth)?;
894
895 PswapNote::builder()
896 .sender(consumer_account_id)
897 .storage(new_storage)
898 .serial_number(remainder_serial_num)
899 .note_type(self.note_type)
900 .offered_asset(remaining_offered_asset)
901 .attachment(attachment)
902 .build()
903 }
904}
905
906impl From<PswapNote> for Note {
911 fn from(pswap: PswapNote) -> Self {
912 let tag = PswapNote::create_tag(
913 pswap.note_type,
914 &pswap.offered_asset,
915 pswap.storage.min_requested_asset(),
916 );
917
918 let recipient = pswap.storage.into_recipient(pswap.serial_number);
919
920 let assets = NoteAssets::new(vec![pswap.offered_asset.into()])
921 .expect("single fungible asset should be valid");
922
923 let metadata = PartialNoteMetadata::new(pswap.sender, pswap.note_type).with_tag(tag);
924
925 let attachments = pswap.attachment.map(NoteAttachments::from).unwrap_or_default();
926
927 Note::with_attachments(assets, metadata, recipient, attachments)
928 }
929}
930
931impl TryFrom<&Note> for PswapNote {
933 type Error = NoteError;
934
935 fn try_from(note: &Note) -> Result<Self, Self::Error> {
936 if note.recipient().script().root() != PswapNote::script_root() {
937 return Err(NoteError::other("note script root does not match PSWAP script root"));
938 }
939
940 let storage = PswapNoteStorage::try_from(note.recipient().storage().items())?;
941
942 if note.assets().num_assets() != 1 {
943 return Err(NoteError::other("PSWAP note must have exactly one asset"));
944 }
945 let offered_asset = note
946 .assets()
947 .iter()
948 .next()
949 .expect("number of assets should have been validated")
950 .as_fungible()
951 .ok_or_else(|| NoteError::other("PSWAP note asset must be fungible"))?;
952
953 let attachment = match note.attachments().num_attachments() {
954 0 => None,
955 1 => {
956 Some(note.attachments().get(0).expect("length should have been validated").clone())
957 },
958 _ => return Err(NoteError::other("pswap note supports only one attachment")),
959 };
960
961 PswapNote::builder()
962 .sender(note.metadata().sender())
963 .storage(storage)
964 .serial_number(note.recipient().serial_num())
965 .note_type(note.metadata().note_type())
966 .offered_asset(offered_asset)
967 .maybe_attachment(attachment)
968 .build()
969 }
970}
971
972impl NoteConsumptionCost for PswapNote {
976 fn consumption_cycles() -> u32 {
977 PSWAP_CONSUMPTION_CYCLES
978 }
979
980 fn created_notes() -> Vec<NoteScriptRoot> {
983 vec![P2idNote::script_root(), PswapNote::script_root()]
984 }
985}
986
987#[cfg(test)]
991mod tests {
992 use miden_protocol::account::{AccountId, AccountIdVersion, AccountType, AssetCallbackFlag};
993 use miden_protocol::asset::FungibleAsset;
994 use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
995 use rstest::rstest;
996
997 use super::*;
998
999 fn dummy_faucet_id(byte: u8) -> AccountId {
1003 AccountId::builder()
1004 .account_type(AccountType::Public)
1005 .build_with_seed([byte; 32])
1006 }
1007
1008 fn dummy_creator_id() -> AccountId {
1009 AccountId::builder().account_type(AccountType::Public).build_with_seed([1; 32])
1010 }
1011
1012 fn dummy_consumer_id() -> AccountId {
1013 AccountId::builder().account_type(AccountType::Public).build_with_seed([2; 32])
1014 }
1015
1016 fn build_pswap_note(
1017 offered_asset: FungibleAsset,
1018 min_requested_asset: FungibleAsset,
1019 creator_id: AccountId,
1020 ) -> (PswapNote, Note) {
1021 let mut rng = RandomCoin::new(Word::default());
1022 let storage = PswapNoteStorage::builder()
1023 .min_requested_asset(min_requested_asset)
1024 .creator_account_id(creator_id)
1025 .build();
1026 let pswap = PswapNote::builder()
1027 .sender(creator_id)
1028 .storage(storage)
1029 .serial_number(rng.draw_word())
1030 .note_type(NoteType::Public)
1031 .offered_asset(offered_asset)
1032 .build()
1033 .unwrap();
1034 let note: Note = pswap.clone().into();
1035 (pswap, note)
1036 }
1037
1038 #[test]
1042 fn pswap_note_creation_and_script() {
1043 let creator_id = dummy_creator_id();
1044 let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap();
1045 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap();
1046
1047 let (pswap, note) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1048
1049 assert_eq!(pswap.sender(), creator_id);
1050 assert_eq!(pswap.note_type(), NoteType::Public);
1051
1052 let script = PswapNote::script();
1053 assert!(Word::from(script.root()) != Word::default(), "Script root should not be zero");
1054 assert_eq!(note.metadata().sender(), creator_id);
1055 assert_eq!(note.metadata().note_type(), NoteType::Public);
1056 assert_eq!(note.assets().num_assets(), 1);
1057 assert_eq!(note.recipient().script().root(), script.root());
1058 assert_eq!(
1059 note.recipient().storage().num_items(),
1060 PswapNoteStorage::NUM_STORAGE_ITEMS as u16,
1061 );
1062 }
1063
1064 #[test]
1065 fn pswap_note_builder() {
1066 let creator_id = dummy_creator_id();
1067 let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap();
1068 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap();
1069
1070 let (pswap, note) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1071
1072 assert_eq!(pswap.sender(), creator_id);
1073 assert_eq!(pswap.note_type(), NoteType::Public);
1074 assert_eq!(note.metadata().sender(), creator_id);
1075 assert_eq!(note.metadata().note_type(), NoteType::Public);
1076 assert_eq!(note.assets().num_assets(), 1);
1077 assert_eq!(
1078 note.recipient().storage().num_items(),
1079 PswapNoteStorage::NUM_STORAGE_ITEMS as u16,
1080 );
1081 }
1082
1083 #[test]
1084 fn pswap_tag() {
1085 let mut offered_faucet_bytes = [0; 15];
1086 offered_faucet_bytes[0] = 0xcd;
1087 offered_faucet_bytes[1] = 0xb1;
1088
1089 let mut requested_faucet_bytes = [0; 15];
1090 requested_faucet_bytes[0] = 0xab;
1091 requested_faucet_bytes[1] = 0xec;
1092
1093 let offered_asset = FungibleAsset::new(
1094 AccountId::dummy(
1095 offered_faucet_bytes,
1096 AccountIdVersion::Version1,
1097 AccountType::Public,
1098 AssetCallbackFlag::Disabled,
1099 ),
1100 100,
1101 )
1102 .unwrap();
1103 let min_requested_asset = FungibleAsset::new(
1104 AccountId::dummy(
1105 requested_faucet_bytes,
1106 AccountIdVersion::Version1,
1107 AccountType::Public,
1108 AssetCallbackFlag::Disabled,
1109 ),
1110 200,
1111 )
1112 .unwrap();
1113
1114 let tag = PswapNote::create_tag(NoteType::Public, &offered_asset, &min_requested_asset);
1115 let tag_u32 = u32::from(tag);
1116
1117 let note_type_bits = tag_u32 >> 30;
1119 assert_eq!(note_type_bits, NoteType::Public as u32);
1120 }
1121
1122 #[test]
1123 fn calculate_output_amount() {
1124 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();
1130 assert!(result > 0, "Should produce non-zero output");
1131 }
1132
1133 #[test]
1134 fn pswap_note_storage_try_from() {
1135 let creator_id = dummy_creator_id();
1136 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1137
1138 let storage_items = vec![
1141 min_requested_asset.faucet_id().suffix(),
1142 min_requested_asset.faucet_id().prefix().as_felt(),
1143 Felt::from(min_requested_asset.amount()),
1144 Felt::try_from(100u64).unwrap(), Felt::from(NoteType::Private.as_u8()), creator_id.suffix(),
1147 creator_id.prefix().as_felt(),
1148 ];
1149
1150 let parsed = PswapNoteStorage::try_from(storage_items.as_slice()).unwrap();
1151 assert_eq!(parsed.creator_account_id(), creator_id);
1152 assert_eq!(parsed.min_requested_amount(), 500);
1153 assert_eq!(parsed.min_fill_step().as_u64(), 100);
1154 }
1155
1156 #[test]
1157 fn pswap_note_storage_roundtrip() {
1158 let creator_id = dummy_creator_id();
1159 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1160
1161 let storage = PswapNoteStorage::builder()
1162 .min_requested_asset(min_requested_asset)
1163 .creator_account_id(creator_id)
1164 .min_fill_step(AssetAmount::new(42).unwrap())
1165 .build();
1166
1167 let note_storage = NoteStorage::from(storage.clone());
1168 assert_eq!(note_storage.num_items(), PswapNoteStorage::NUM_STORAGE_ITEMS as u16);
1169
1170 let parsed = PswapNoteStorage::try_from(note_storage.items()).unwrap();
1171
1172 assert_eq!(parsed.creator_account_id(), creator_id);
1173 assert_eq!(parsed.min_requested_amount(), 500);
1174 assert_eq!(parsed.min_fill_step().as_u64(), 42);
1175 }
1176
1177 #[test]
1178 fn pswap_note_storage_defaults_min_fill_step_to_zero() {
1179 let creator_id = dummy_creator_id();
1180 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1181
1182 let storage = PswapNoteStorage::builder()
1183 .min_requested_asset(min_requested_asset)
1184 .creator_account_id(creator_id)
1185 .build();
1186
1187 assert_eq!(
1188 storage.min_fill_step(),
1189 AssetAmount::ZERO,
1190 "min_fill_step must default to zero (no floor)",
1191 );
1192 }
1193
1194 #[rstest]
1199 #[case::below_floor(100, 30, 29, 0, false)]
1201 #[case::equal_floor(100, 30, 30, 0, true)]
1202 #[case::above_floor(100, 30, 50, 0, true)]
1203 #[case::clamped_full_fill(20, 50, 20, 0, true)]
1206 #[case::clamped_below_both(20, 50, 10, 0, false)]
1207 #[case::two_legs_meet_floor(100, 30, 20, 20, true)]
1209 #[case::two_legs_below_floor(100, 30, 10, 10, false)]
1210 fn pswap_execute_enforces_min_fill_step(
1211 #[case] min_requested: u64,
1212 #[case] min_fill_step: u64,
1213 #[case] account_fill: u64,
1214 #[case] note_fill: u64,
1215 #[case] expect_ok: bool,
1216 ) {
1217 let creator_id = dummy_creator_id();
1218 let consumer_id = dummy_consumer_id();
1219 let offered_faucet = dummy_faucet_id(0xaa);
1220 let requested_faucet = dummy_faucet_id(0xbb);
1221
1222 let offered_asset = FungibleAsset::new(offered_faucet, 200).unwrap();
1223 let min_requested_asset = FungibleAsset::new(requested_faucet, min_requested).unwrap();
1224 let storage = PswapNoteStorage::builder()
1225 .min_requested_asset(min_requested_asset)
1226 .creator_account_id(creator_id)
1227 .min_fill_step(AssetAmount::new(min_fill_step).unwrap())
1228 .build();
1229 let mut rng = RandomCoin::new(Word::default());
1230 let pswap = PswapNote::builder()
1231 .sender(creator_id)
1232 .storage(storage)
1233 .serial_number(rng.draw_word())
1234 .note_type(NoteType::Public)
1235 .offered_asset(offered_asset)
1236 .build()
1237 .unwrap();
1238
1239 let leg = |amt: u64| (amt > 0).then(|| FungibleAsset::new(requested_faucet, amt).unwrap());
1240 let result = pswap.execute(consumer_id, leg(account_fill), leg(note_fill));
1241
1242 assert_eq!(result.is_ok(), expect_ok, "unexpected accept/reject for this fill");
1243
1244 if let Ok((_, remainder)) = result {
1245 if account_fill + note_fill < min_requested {
1248 let rem = remainder.expect("partial fill should produce a remainder");
1249 assert_eq!(
1250 rem.storage().min_fill_step().as_u64(),
1251 min_fill_step,
1252 "remainder must inherit min_fill_step",
1253 );
1254 } else {
1255 assert!(remainder.is_none(), "full fill must complete the swap with no remainder");
1256 }
1257 }
1258 }
1259
1260 #[test]
1265 fn pswap_execute_combined_account_fill_and_note_fill_partial_fill() {
1266 let creator_id = dummy_creator_id();
1267 let consumer_id = dummy_consumer_id();
1268 let offered_faucet = dummy_faucet_id(0xaa);
1269 let requested_faucet = dummy_faucet_id(0xbb);
1270
1271 let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1273 let min_requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1274 let (pswap, _) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1275
1276 let account_fill = FungibleAsset::new(requested_faucet, 10).unwrap();
1278 let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1279
1280 let (payback, remainder) =
1281 pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap();
1282
1283 assert_eq!(payback.assets().num_assets(), 1);
1285 let payback_asset = payback.assets().iter().next().unwrap();
1286 let fa = payback_asset.unwrap_fungible();
1287 assert_eq!(fa.faucet_id(), requested_faucet);
1288 assert_eq!(fa.amount().as_u64(), 30);
1289
1290 let remainder = remainder.expect("partial fill should produce remainder");
1293 assert_eq!(remainder.storage().min_requested_amount(), 20);
1294 assert_eq!(remainder.offered_asset().amount().as_u64(), 40);
1295 assert_eq!(remainder.storage().creator_account_id(), creator_id);
1296 }
1297
1298 #[test]
1302 fn pswap_execute_combined_account_fill_and_note_fill_full_fill() {
1303 let creator_id = dummy_creator_id();
1304 let consumer_id = dummy_consumer_id();
1305 let offered_faucet = dummy_faucet_id(0xaa);
1306 let requested_faucet = dummy_faucet_id(0xbb);
1307
1308 let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1309 let min_requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1310 let (pswap, _) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1311
1312 let account_fill = FungibleAsset::new(requested_faucet, 30).unwrap();
1314 let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1315
1316 let (payback, remainder) =
1317 pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap();
1318
1319 assert_eq!(payback.assets().num_assets(), 1);
1321 let payback_asset = payback.assets().iter().next().unwrap();
1322 let fa = payback_asset.unwrap_fungible();
1323 assert_eq!(fa.faucet_id(), requested_faucet);
1324 assert_eq!(fa.amount().as_u64(), 50);
1325
1326 assert!(remainder.is_none(), "full fill must not produce a remainder");
1328 }
1329
1330 #[rstest]
1334 #[case::above_u32(Felt::new_unchecked(u64::from(u32::MAX) + 1))]
1335 #[case::wraps_the_field(Felt::MAX)]
1336 fn pswap_rejects_out_of_range_attachment_depth(#[case] depth: Felt) {
1337 let creator_id = dummy_creator_id();
1338 let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 100).unwrap();
1339 let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 50).unwrap();
1340
1341 let storage = PswapNoteStorage::builder()
1342 .min_requested_asset(min_requested_asset)
1343 .creator_account_id(creator_id)
1344 .build();
1345 let attachment = NoteAttachment::with_word(
1346 PswapNote::PSWAP_ATTACHMENT_SCHEME,
1347 Word::from([ONE, ONE, depth, ZERO]),
1348 );
1349
1350 let result = PswapNote::builder()
1351 .sender(creator_id)
1352 .storage(storage)
1353 .serial_number(RandomCoin::new(Word::default()).draw_word())
1354 .note_type(NoteType::Public)
1355 .offered_asset(offered_asset)
1356 .attachment(attachment)
1357 .build();
1358
1359 assert!(result.is_err(), "an out-of-range depth must not build a PswapNote");
1360 }
1361
1362 #[test]
1366 fn pswap_lineage_helpers_are_relative_to_the_parent_depth() {
1367 let creator_id = dummy_creator_id();
1368 let consumer_id = dummy_consumer_id();
1369 let offered_faucet = dummy_faucet_id(0xaa);
1370 let requested_faucet = dummy_faucet_id(0xbb);
1371
1372 let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1373 let min_requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1374 let (original, _) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1375
1376 let fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1378 let (_, remainder) = original.execute(consumer_id, Some(fill), None).unwrap();
1379 let remainder = remainder.expect("partial fill should produce a remainder");
1380 assert_eq!(remainder.parent_depth(), 1);
1381
1382 let (round_two_payback, _) = remainder.execute(consumer_id, Some(fill), None).unwrap();
1383 let round_one_attachment = PswapNoteAttachment::try_from(
1384 remainder.attachments().expect("remainder carries an attachment"),
1385 )
1386 .unwrap();
1387 let round_two_attachment = PswapNoteAttachment::new(
1388 AssetAmount::new(20).unwrap(),
1389 round_one_attachment.order_id(),
1390 2,
1391 );
1392
1393 assert_eq!(
1394 original.payback_note(consumer_id, &round_two_attachment).unwrap().id(),
1395 round_two_payback.id(),
1396 "the original must reconstruct round 2 from its absolute depth",
1397 );
1398 assert_eq!(
1399 remainder.payback_note(consumer_id, &round_two_attachment).unwrap().id(),
1400 round_two_payback.id(),
1401 "the round's own parent must reconstruct it as well",
1402 );
1403 assert!(
1404 remainder.payback_note(consumer_id, &round_one_attachment).is_err(),
1405 "an attachment from the parent's own round is not a later round",
1406 );
1407 }
1408}