1use alloc::collections::BTreeMap;
6use alloc::string::ToString;
7
8use miden_protocol::account::AccountId;
9use miden_protocol::asset::AssetAmount;
10use miden_protocol::block::{BlockHeader, BlockNumber};
11use miden_protocol::note::{Note, NoteId, NoteInclusionProof, NoteTag};
12use miden_protocol::{Felt, Word};
13use miden_standards::note::{PswapNote, PswapNoteAttachment};
14
15use super::errors::PswapLineageError;
16use crate::utils::{ByteReader, ByteWriter, Deserializable, DeserializationError, Serializable};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[repr(u8)]
25pub enum PswapLineageState {
26 Active = 0,
28 FullyFilled = 1,
30 Reclaimed = 2,
32}
33
34impl PswapLineageState {
35 pub fn as_u8(self) -> u8 {
36 self as u8
37 }
38
39 pub fn try_from_u8(value: u8) -> Result<Self, PswapLineageError> {
41 match value {
42 0 => Ok(Self::Active),
43 1 => Ok(Self::FullyFilled),
44 2 => Ok(Self::Reclaimed),
45 other => Err(PswapLineageError::UnknownState(other)),
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
60pub struct PswapLineageRecord {
61 pub original_note_id: NoteId,
64
65 order_id: Felt,
68 creator_account_id: AccountId,
69
70 pub current_tip_note_id: NoteId,
73 pub current_depth: u32,
75 pub remaining_offered: AssetAmount,
78 pub remaining_requested: AssetAmount,
80 pub state: PswapLineageState,
81}
82
83impl PswapLineageRecord {
84 pub fn new_depth_zero(original_note_id: NoteId, pswap: &PswapNote) -> Self {
88 Self {
89 original_note_id,
90 order_id: pswap.order_id(),
91 creator_account_id: pswap.storage().creator_account_id(),
92 current_tip_note_id: original_note_id,
93 current_depth: 0,
94 remaining_offered: pswap.offered_asset().amount(),
95 remaining_requested: pswap.storage().min_requested_asset().amount(),
96 state: PswapLineageState::Active,
97 }
98 }
99
100 pub fn order_id(&self) -> Felt {
102 self.order_id
103 }
104
105 pub fn creator_account_id(&self) -> AccountId {
107 self.creator_account_id
108 }
109}
110
111#[derive(Debug, Clone)]
117pub(crate) struct PswapLineageRoundUpdate {
118 pub order_id: Felt,
119 pub round_depth: u32,
120 pub remaining_offered: AssetAmount,
122 pub remaining_requested: AssetAmount,
123 pub state: PswapLineageState,
124 pub tip_note_id: Option<NoteId>,
126 pub at_block_note_root: Option<Word>,
129 pub payback: Option<(Note, NoteInclusionProof)>,
132 pub remainder: Option<(Note, NoteInclusionProof)>,
135}
136
137#[derive(Debug, Clone)]
143pub(crate) struct ObservedPswapNote {
144 pub note_id: NoteId,
145 pub attachment: PswapNoteAttachment,
146 pub sender: AccountId,
147 pub tag: NoteTag,
149 pub block_num: BlockNumber,
150 pub inclusion_proof: NoteInclusionProof,
151}
152
153impl PswapLineageRecord {
157 pub(crate) fn build_round_update(
169 &self,
170 round_depth: u32,
171 notes: &[&ObservedPswapNote],
172 block_headers: &BTreeMap<BlockNumber, BlockHeader>,
173 original_pswap: Option<&PswapNote>,
174 tip_consumed: bool,
175 ) -> Result<Option<PswapLineageRoundUpdate>, PswapLineageError> {
176 if notes.is_empty() {
178 return Ok(Some(self.build_reclaim_round(round_depth)));
179 }
180
181 let pswap = original_pswap
183 .ok_or(PswapLineageError::OriginalNoteUnavailable(self.original_note_id))?;
184 let payback_tag = pswap.storage().payback_note_tag();
185
186 let Some((observed_payback, payback_note)) = notes
190 .iter()
191 .copied()
192 .filter(|note| note.tag == payback_tag)
193 .find_map(|note| validate_payback(pswap, note).map(|recon| (note, recon)))
194 else {
195 return Ok(tip_consumed.then(|| self.build_reclaim_round(round_depth)));
198 };
199
200 let fill_amount = observed_payback.attachment.amount();
202
203 let remainder =
206 notes.iter().copied().filter(|note| note.tag != payback_tag).find_map(|note| {
207 self.validate_remainder(pswap, note, fill_amount).map(|recon| (note, recon))
208 });
209
210 Ok(Some(match remainder {
211 Some((observed_remainder, remainder_note)) => self.build_partial_fill_round(
212 round_depth,
213 observed_payback,
214 payback_note,
215 observed_remainder,
216 remainder_note,
217 fill_amount,
218 block_headers,
219 ),
220 None => self.build_full_fill_round(
221 round_depth,
222 observed_payback,
223 payback_note,
224 block_headers,
225 ),
226 }))
227 }
228
229 fn validate_remainder(
233 &self,
234 pswap: &PswapNote,
235 observed: &ObservedPswapNote,
236 fill_amount: AssetAmount,
237 ) -> Option<Note> {
238 let payout_amount = observed.attachment.amount();
239 let (remaining_offered, remaining_requested) =
240 self.remaining_after_fill(fill_amount, payout_amount);
241 let remainder_note = pswap
242 .remainder_note(
243 observed.sender,
244 &observed.attachment,
245 remaining_offered,
246 remaining_requested,
247 )
248 .ok()?;
249 (remainder_note.id() == observed.note_id).then_some(remainder_note)
250 }
251
252 fn remaining_after_fill(
255 &self,
256 fill_amount: AssetAmount,
257 payout_amount: AssetAmount,
258 ) -> (AssetAmount, AssetAmount) {
259 (
260 saturating_sub(self.remaining_offered, payout_amount),
261 saturating_sub(self.remaining_requested, fill_amount),
262 )
263 }
264
265 fn build_reclaim_round(&self, round_depth: u32) -> PswapLineageRoundUpdate {
267 PswapLineageRoundUpdate {
268 order_id: self.order_id(),
269 round_depth,
270 remaining_offered: AssetAmount::ZERO,
271 remaining_requested: AssetAmount::ZERO,
272 state: PswapLineageState::Reclaimed,
273 tip_note_id: None,
274 at_block_note_root: None,
275 payback: None,
276 remainder: None,
277 }
278 }
279
280 fn build_full_fill_round(
283 &self,
284 round_depth: u32,
285 observed_payback: &ObservedPswapNote,
286 payback_note: Note,
287 block_headers: &BTreeMap<BlockNumber, BlockHeader>,
288 ) -> PswapLineageRoundUpdate {
289 PswapLineageRoundUpdate {
290 order_id: self.order_id(),
291 round_depth,
292 remaining_offered: AssetAmount::ZERO,
293 remaining_requested: AssetAmount::ZERO,
294 state: PswapLineageState::FullyFilled,
295 tip_note_id: None,
296 at_block_note_root: block_headers
297 .get(&observed_payback.block_num)
298 .map(BlockHeader::note_root),
299 payback: Some((payback_note, observed_payback.inclusion_proof.clone())),
300 remainder: None,
301 }
302 }
303
304 #[allow(clippy::too_many_arguments)]
307 fn build_partial_fill_round(
308 &self,
309 round_depth: u32,
310 observed_payback: &ObservedPswapNote,
311 payback_note: Note,
312 observed_remainder: &ObservedPswapNote,
313 remainder_note: Note,
314 fill_amount: AssetAmount,
315 block_headers: &BTreeMap<BlockNumber, BlockHeader>,
316 ) -> PswapLineageRoundUpdate {
317 let payout_amount = observed_remainder.attachment.amount();
318 let (remaining_offered, remaining_requested) =
319 self.remaining_after_fill(fill_amount, payout_amount);
320
321 PswapLineageRoundUpdate {
322 order_id: self.order_id(),
323 round_depth,
324 remaining_offered,
325 remaining_requested,
326 state: PswapLineageState::Active,
327 tip_note_id: Some(observed_remainder.note_id),
328 at_block_note_root: block_headers
329 .get(&observed_payback.block_num)
330 .map(BlockHeader::note_root),
331 payback: Some((payback_note, observed_payback.inclusion_proof.clone())),
332 remainder: Some((remainder_note, observed_remainder.inclusion_proof.clone())),
333 }
334 }
335
336 pub(crate) fn advance(mut self, update: &PswapLineageRoundUpdate) -> PswapLineageRecord {
339 self.current_depth = update.round_depth;
340 self.remaining_offered = update.remaining_offered;
341 self.remaining_requested = update.remaining_requested;
342 self.state = update.state;
343 if let Some(note_id) = update.tip_note_id {
344 self.current_tip_note_id = note_id;
345 }
346 self
347 }
348}
349
350fn validate_payback(pswap: &PswapNote, observed: &ObservedPswapNote) -> Option<Note> {
353 let payback_note = pswap.payback_note(observed.sender, &observed.attachment).ok()?;
354 (payback_note.id() == observed.note_id).then_some(payback_note)
355}
356
357fn saturating_sub(total: AssetAmount, used: AssetAmount) -> AssetAmount {
359 AssetAmount::new(total.as_u64().saturating_sub(used.as_u64()))
360 .expect("a value <= an existing AssetAmount is itself a valid AssetAmount")
361}
362
363#[derive(Debug, Clone)]
369pub(crate) enum PswapLineageFilter {
370 All,
371 Active,
372 ByCreator(AccountId),
373}
374
375#[allow(clippy::too_many_arguments)]
382pub(crate) fn build_record_from_fields(
383 original_note_id: NoteId,
384 order_id: Felt,
385 creator_account_id: AccountId,
386 current_tip_note_id: NoteId,
387 current_depth: u32,
388 remaining_offered: AssetAmount,
389 remaining_requested: AssetAmount,
390 state_byte: u8,
391) -> Result<PswapLineageRecord, PswapLineageError> {
392 Ok(PswapLineageRecord {
393 original_note_id,
394 order_id,
395 creator_account_id,
396 current_tip_note_id,
397 current_depth,
398 remaining_offered,
399 remaining_requested,
400 state: PswapLineageState::try_from_u8(state_byte)?,
401 })
402}
403
404impl Serializable for PswapLineageRecord {
412 fn write_into<W: ByteWriter>(&self, target: &mut W) {
413 self.original_note_id.write_into(target);
414 self.order_id.write_into(target);
415 self.creator_account_id.write_into(target);
416 self.current_tip_note_id.write_into(target);
417 self.current_depth.write_into(target);
418 self.remaining_offered.write_into(target);
419 self.remaining_requested.write_into(target);
420 self.state.as_u8().write_into(target);
421 }
422}
423
424impl Deserializable for PswapLineageRecord {
425 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
426 let original_note_id = NoteId::read_from(source)?;
427 let order_id = Felt::read_from(source)?;
428 let creator_account_id = AccountId::read_from(source)?;
429 let current_tip_note_id = NoteId::read_from(source)?;
430 let current_depth = u32::read_from(source)?;
431 let remaining_offered = AssetAmount::read_from(source)?;
432 let remaining_requested = AssetAmount::read_from(source)?;
433 let state_byte = u8::read_from(source)?;
434 build_record_from_fields(
435 original_note_id,
436 order_id,
437 creator_account_id,
438 current_tip_note_id,
439 current_depth,
440 remaining_offered,
441 remaining_requested,
442 state_byte,
443 )
444 .map_err(|err| DeserializationError::InvalidValue(err.to_string()))
445 }
446}
447
448#[cfg(test)]
449pub(crate) mod test_helpers {
450 use miden_protocol::Word;
453 use miden_protocol::account::AccountId;
454 use miden_protocol::asset::FungibleAsset;
455 use miden_protocol::note::NoteType;
456 use miden_protocol::testing::account_id::{
457 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
458 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1,
459 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
460 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
461 };
462 use miden_standards::note::{PswapNote, PswapNoteStorage};
463
464 pub fn fixed_account_ids() -> (AccountId, AccountId, AccountId, AccountId) {
467 (
468 AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(),
469 AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2).unwrap(),
470 AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(),
471 AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).unwrap(),
472 )
473 }
474
475 pub fn build_test_pswap(
478 sender: AccountId,
479 creator: AccountId,
480 offered_faucet: AccountId,
481 offered_amount: u64,
482 requested_faucet: AccountId,
483 requested_amount: u64,
484 ) -> PswapNote {
485 let offered = FungibleAsset::new(offered_faucet, offered_amount).unwrap();
486 let requested = FungibleAsset::new(requested_faucet, requested_amount).unwrap();
487 let storage = PswapNoteStorage::builder()
488 .min_requested_asset(requested)
489 .creator_account_id(creator)
490 .build();
491 PswapNote::builder()
492 .sender(sender)
493 .storage(storage)
494 .serial_number(Word::from([
495 miden_protocol::Felt::new(1).unwrap(),
496 miden_protocol::Felt::new(2).unwrap(),
497 miden_protocol::Felt::new(3).unwrap(),
498 miden_protocol::Felt::new(4).unwrap(),
499 ]))
500 .note_type(NoteType::Public)
501 .offered_asset(offered)
502 .build()
503 .unwrap()
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use alloc::vec::Vec;
510
511 use miden_protocol::asset::AssetAmount;
512 use miden_protocol::crypto::merkle::SparseMerklePath;
513 use miden_standards::note::PswapNote;
514
515 use super::test_helpers::{build_test_pswap, fixed_account_ids};
516 use super::*;
517
518 fn record_from_test_pswap(
522 pswap: &PswapNote,
523 current_tip_note_id: NoteId,
524 current_depth: u32,
525 remaining_offered: u64,
526 remaining_requested: u64,
527 state_byte: u8,
528 ) -> Result<PswapLineageRecord, PswapLineageError> {
529 let original_note_id = miden_protocol::note::Note::from(pswap.clone()).id();
530 build_record_from_fields(
531 original_note_id,
532 pswap.order_id(),
533 pswap.storage().creator_account_id(),
534 current_tip_note_id,
535 current_depth,
536 AssetAmount::new(remaining_offered).unwrap(),
537 AssetAmount::new(remaining_requested).unwrap(),
538 state_byte,
539 )
540 }
541
542 #[test]
545 fn state_byte_encoding_is_stable() {
546 assert_eq!(PswapLineageState::Active.as_u8(), 0);
547 assert_eq!(PswapLineageState::FullyFilled.as_u8(), 1);
548 assert_eq!(PswapLineageState::Reclaimed.as_u8(), 2);
549 }
550
551 #[test]
554 fn state_try_from_u8_round_trips_known_variants() {
555 for state in [
556 PswapLineageState::Active,
557 PswapLineageState::FullyFilled,
558 PswapLineageState::Reclaimed,
559 ] {
560 assert_eq!(PswapLineageState::try_from_u8(state.as_u8()).unwrap(), state);
561 }
562 }
563
564 #[test]
567 fn state_try_from_u8_rejects_unknown() {
568 match PswapLineageState::try_from_u8(99) {
569 Err(PswapLineageError::UnknownState(99)) => {},
570 other => panic!("expected UnknownState(99), got {other:?}"),
571 }
572 }
573
574 #[test]
576 fn build_record_from_fields_accepts_valid_depth_zero_record() {
577 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
578 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
579 let initial_note_id = miden_protocol::note::Note::from(pswap.clone()).id();
580
581 let record = record_from_test_pswap(
582 &pswap,
583 initial_note_id,
584 0,
585 100,
586 50,
587 PswapLineageState::Active.as_u8(),
588 )
589 .unwrap();
590
591 assert_eq!(record.current_depth, 0);
592 assert_eq!(record.remaining_offered, AssetAmount::new(100).unwrap());
593 assert_eq!(record.remaining_requested, AssetAmount::new(50).unwrap());
594 assert_eq!(record.state, PswapLineageState::Active);
595 }
596
597 #[test]
599 fn build_record_from_fields_accepts_valid_advanced_record() {
600 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
601 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
602 let note = miden_protocol::note::Note::from(pswap.clone());
603 let record =
604 record_from_test_pswap(&pswap, note.id(), 3, 70, 35, PswapLineageState::Active.as_u8())
605 .unwrap();
606
607 assert_eq!(record.current_depth, 3);
608 assert_eq!(record.remaining_offered, AssetAmount::new(70).unwrap());
609 }
610
611 #[test]
613 fn build_record_from_fields_rejects_unknown_state() {
614 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
615 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
616 let note = miden_protocol::note::Note::from(pswap.clone());
617 match record_from_test_pswap(&pswap, note.id(), 0, 100, 50, 42) {
618 Err(PswapLineageError::UnknownState(42)) => {},
619 other => panic!("expected UnknownState(42), got {other:?}"),
620 }
621 }
622
623 #[test]
626 fn accessors_mirror_depth_zero_note() {
627 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
628 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
629
630 let expected_order_id = pswap.order_id();
631
632 let note = miden_protocol::note::Note::from(pswap.clone());
633 let record = record_from_test_pswap(
634 &pswap,
635 note.id(),
636 0,
637 100,
638 50,
639 PswapLineageState::Active.as_u8(),
640 )
641 .unwrap();
642
643 assert_eq!(record.original_note_id, note.id());
644 assert_eq!(record.order_id(), expected_order_id);
645 assert_eq!(record.creator_account_id(), creator);
646 }
647
648 #[test]
651 fn value_codec_round_trips() {
652 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
653 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
654 let note = miden_protocol::note::Note::from(pswap.clone());
655 let record =
656 record_from_test_pswap(&pswap, note.id(), 3, 70, 35, PswapLineageState::Active.as_u8())
657 .unwrap();
658
659 let bytes = record.to_bytes();
660 let decoded = PswapLineageRecord::read_from_bytes(&bytes).unwrap();
661
662 assert_eq!(decoded.original_note_id, record.original_note_id);
663 assert_eq!(decoded.creator_account_id(), record.creator_account_id());
664 assert_eq!(decoded.order_id(), record.order_id());
665 assert_eq!(decoded.current_tip_note_id, record.current_tip_note_id);
666 assert_eq!(decoded.current_depth, record.current_depth);
667 assert_eq!(decoded.remaining_offered, record.remaining_offered);
668 assert_eq!(decoded.remaining_requested, record.remaining_requested);
669 assert_eq!(decoded.remaining_offered, AssetAmount::new(70).unwrap());
670 assert_eq!(decoded.remaining_requested, AssetAmount::new(35).unwrap());
671 assert_eq!(decoded.state, record.state);
672 }
673
674 fn dummy_inclusion_proof(block: u32) -> NoteInclusionProof {
679 let path =
680 SparseMerklePath::from_parts(0, Vec::new()).expect("empty SparseMerklePath is valid");
681 NoteInclusionProof::new(BlockNumber::from(block), 0, path)
682 .expect("zero index is well below the per-block notes ceiling")
683 }
684
685 fn no_block_headers() -> BTreeMap<BlockNumber, BlockHeader> {
687 BTreeMap::new()
688 }
689
690 fn initial_record(pswap: &PswapNote, offered: u64, requested: u64) -> PswapLineageRecord {
692 let original_note_id = Note::from(pswap.clone()).id();
693 let mut record = PswapLineageRecord::new_depth_zero(original_note_id, pswap);
694 record.remaining_offered =
696 AssetAmount::new(offered).expect("test value fits in AssetAmount");
697 record.remaining_requested =
698 AssetAmount::new(requested).expect("test value fits in AssetAmount");
699 record
700 }
701
702 fn chain_update_from(
705 note: &Note,
706 attachment: PswapNoteAttachment,
707 sender: AccountId,
708 block: u32,
709 ) -> ObservedPswapNote {
710 ObservedPswapNote {
711 note_id: note.id(),
712 attachment,
713 sender,
714 tag: note.metadata().tag(),
715 block_num: BlockNumber::from(block),
716 inclusion_proof: dummy_inclusion_proof(block),
717 }
718 }
719
720 fn forged_note(
723 forged_id: NoteId,
724 attachment: PswapNoteAttachment,
725 tag: NoteTag,
726 sender: AccountId,
727 block: u32,
728 ) -> ObservedPswapNote {
729 ObservedPswapNote {
730 note_id: forged_id,
731 attachment,
732 sender,
733 tag,
734 block_num: BlockNumber::from(block),
735 inclusion_proof: dummy_inclusion_proof(block),
736 }
737 }
738
739 fn expect_round(
741 result: Result<Option<PswapLineageRoundUpdate>, PswapLineageError>,
742 ) -> PswapLineageRoundUpdate {
743 result
744 .expect("build_round_update should not error")
745 .expect("expected a round update")
746 }
747
748 #[test]
750 fn build_round_update_partial_fill_advances_active() {
751 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
752 let consumer = AccountId::try_from(
753 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
754 )
755 .unwrap();
756 let creator = AccountId::try_from(
757 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
758 )
759 .unwrap();
760
761 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
762 let record = initial_record(&pswap, 100, 50);
763
764 let fill_amount = 20;
765 let payout_amount = 40;
766 let new_off = 100 - payout_amount;
767 let new_req = 50 - fill_amount;
768
769 let payback_att =
770 PswapNoteAttachment::new(AssetAmount::new(fill_amount).unwrap(), pswap.order_id(), 1);
771 let remainder_att =
772 PswapNoteAttachment::new(AssetAmount::new(payout_amount).unwrap(), pswap.order_id(), 1);
773 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
774 let remainder = pswap
775 .remainder_note(
776 consumer,
777 &remainder_att,
778 AssetAmount::new(new_off).unwrap(),
779 AssetAmount::new(new_req).unwrap(),
780 )
781 .unwrap();
782
783 let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
784 let cand_remainder = chain_update_from(&remainder, remainder_att, consumer, 7);
785
786 let update = expect_round(record.build_round_update(
787 1,
788 &[&cand_payback, &cand_remainder],
789 &no_block_headers(),
790 Some(&pswap),
791 true,
792 ));
793
794 assert_eq!(update.round_depth, 1);
795 assert_eq!(update.remaining_offered, AssetAmount::new(new_off).unwrap());
796 assert_eq!(update.remaining_requested, AssetAmount::new(new_req).unwrap());
797 assert_eq!(update.state, PswapLineageState::Active);
798 assert_eq!(update.tip_note_id, Some(remainder.id()));
799 assert!(update.payback.is_some());
801 assert!(update.remainder.is_some());
802 }
803
804 #[test]
808 fn build_round_update_partial_fill_classifies_regardless_of_note_order() {
809 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
810 let consumer = AccountId::try_from(
811 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
812 )
813 .unwrap();
814 let creator = AccountId::try_from(
815 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
816 )
817 .unwrap();
818
819 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
820 let record = initial_record(&pswap, 100, 50);
821
822 let fill_amount = 20;
823 let payout_amount = 40;
824 let new_off = 100 - payout_amount;
825 let new_req = 50 - fill_amount;
826
827 let payback_att =
828 PswapNoteAttachment::new(AssetAmount::new(fill_amount).unwrap(), pswap.order_id(), 1);
829 let remainder_att =
830 PswapNoteAttachment::new(AssetAmount::new(payout_amount).unwrap(), pswap.order_id(), 1);
831 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
832 let remainder = pswap
833 .remainder_note(
834 consumer,
835 &remainder_att,
836 AssetAmount::new(new_off).unwrap(),
837 AssetAmount::new(new_req).unwrap(),
838 )
839 .unwrap();
840
841 let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
842 let cand_remainder = chain_update_from(&remainder, remainder_att, consumer, 7);
843
844 let update = expect_round(record.build_round_update(
846 1,
847 &[&cand_remainder, &cand_payback],
848 &no_block_headers(),
849 Some(&pswap),
850 true,
851 ));
852
853 assert_eq!(update.tip_note_id, Some(remainder.id()));
854 assert_eq!(update.state, PswapLineageState::Active);
855 }
856
857 #[test]
862 fn build_round_update_filters_unreconstructable_candidate() {
863 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
864 let consumer = AccountId::try_from(
865 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
866 )
867 .unwrap();
868 let creator = AccountId::try_from(
869 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
870 )
871 .unwrap();
872
873 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
874 let record = initial_record(&pswap, 100, 50);
875
876 let good_att = PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
880 let payback = pswap.payback_note(consumer, &good_att).unwrap();
881 let bad_att = PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 0);
882 let cand = forged_note(payback.id(), bad_att, payback.metadata().tag(), consumer, 5);
883
884 let result =
886 record.build_round_update(1, &[&cand], &no_block_headers(), Some(&pswap), false);
887 assert!(
888 matches!(result, Ok(None)),
889 "unreconstructable candidate must be filtered, not fatal"
890 );
891 }
892
893 #[test]
895 fn build_round_update_full_fill_marks_fully_filled() {
896 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
897 let consumer = AccountId::try_from(
898 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
899 )
900 .unwrap();
901 let creator = AccountId::try_from(
902 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
903 )
904 .unwrap();
905
906 let pswap = build_test_pswap(consumer, creator, offered_faucet, 30, requested_faucet, 50);
908 let record = initial_record(&pswap, 30, 50);
909
910 let fill_amount = 50; let payback_att =
912 PswapNoteAttachment::new(AssetAmount::new(fill_amount).unwrap(), pswap.order_id(), 1);
913 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
914 let cand = chain_update_from(&payback, payback_att, consumer, 9);
915
916 let update = expect_round(record.build_round_update(
917 1,
918 &[&cand],
919 &no_block_headers(),
920 Some(&pswap),
921 true,
922 ));
923
924 assert_eq!(update.state, PswapLineageState::FullyFilled);
925 assert_eq!(update.remaining_offered, AssetAmount::ZERO);
926 assert_eq!(update.remaining_requested, AssetAmount::ZERO);
927 assert_eq!(update.tip_note_id, None);
928 assert!(update.remainder.is_none());
929 }
930
931 #[test]
934 fn build_round_update_zero_outputs_marks_reclaimed_with_remaining_zero() {
935 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
936 let consumer = AccountId::try_from(
937 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
938 )
939 .unwrap();
940 let creator = AccountId::try_from(
941 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
942 )
943 .unwrap();
944
945 let pswap = build_test_pswap(consumer, creator, offered_faucet, 80, requested_faucet, 40);
946 let record = initial_record(&pswap, 80, 40);
947
948 let update =
949 expect_round(record.build_round_update(1, &[], &no_block_headers(), None, true));
950
951 assert_eq!(update.state, PswapLineageState::Reclaimed);
952 assert_eq!(update.remaining_offered, AssetAmount::ZERO);
953 assert_eq!(update.remaining_requested, AssetAmount::ZERO);
955 assert!(update.payback.is_none());
956 }
957
958 #[test]
961 fn advance_chains_correctly_for_multi_fill() {
962 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
963 let consumer = AccountId::try_from(
964 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
965 )
966 .unwrap();
967 let creator = AccountId::try_from(
968 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
969 )
970 .unwrap();
971
972 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
973 let record0 = initial_record(&pswap, 100, 50);
974
975 let fill1 = 20;
977 let payout1 = 40;
978 let new_off1 = 100 - payout1;
979 let new_req1 = 50 - fill1;
980 let payback_att1 =
981 PswapNoteAttachment::new(AssetAmount::new(fill1).unwrap(), pswap.order_id(), 1);
982 let remainder_att1 =
983 PswapNoteAttachment::new(AssetAmount::new(payout1).unwrap(), pswap.order_id(), 1);
984 let payback1 = pswap.payback_note(consumer, &payback_att1).unwrap();
985 let remainder1 = pswap
986 .remainder_note(
987 consumer,
988 &remainder_att1,
989 AssetAmount::new(new_off1).unwrap(),
990 AssetAmount::new(new_req1).unwrap(),
991 )
992 .unwrap();
993 let payback_cand = chain_update_from(&payback1, payback_att1, consumer, 11);
994 let remainder_cand = chain_update_from(&remainder1, remainder_att1, consumer, 11);
995
996 let update1 = expect_round(record0.build_round_update(
997 1,
998 &[&payback_cand, &remainder_cand],
999 &no_block_headers(),
1000 Some(&pswap),
1001 true,
1002 ));
1003
1004 let record1 = record0.advance(&update1);
1006 assert_eq!(record1.current_depth, 1);
1007 assert_eq!(record1.remaining_offered, AssetAmount::new(new_off1).unwrap());
1008 assert_eq!(record1.remaining_requested, AssetAmount::new(new_req1).unwrap());
1009 assert_eq!(record1.current_tip_note_id, remainder1.id());
1010 assert_eq!(record1.state, PswapLineageState::Active);
1011
1012 let fill2 = new_req1; let payback_att2 =
1015 PswapNoteAttachment::new(AssetAmount::new(fill2).unwrap(), pswap.order_id(), 2);
1016 let payback2 = pswap.payback_note(consumer, &payback_att2).unwrap();
1017 let cand_p2 = chain_update_from(&payback2, payback_att2, consumer, 11);
1018
1019 let update2 = expect_round(record1.build_round_update(
1020 2,
1021 &[&cand_p2],
1022 &no_block_headers(),
1023 Some(&pswap),
1024 true,
1025 ));
1026
1027 assert_eq!(update2.round_depth, 2);
1028 assert_eq!(update2.state, PswapLineageState::FullyFilled);
1029 assert_eq!(update2.remaining_offered, AssetAmount::ZERO);
1030 assert_eq!(update2.remaining_requested, AssetAmount::ZERO);
1031
1032 let record2 = record1.advance(&update2);
1033 assert_eq!(record2.state, PswapLineageState::FullyFilled);
1034 let emitted = [update1, update2];
1035 assert_eq!(emitted.len(), 2);
1036 }
1037
1038 #[test]
1042 fn build_round_update_filters_forged_payback() {
1043 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
1044 let consumer = AccountId::try_from(
1045 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1046 )
1047 .unwrap();
1048 let creator = AccountId::try_from(
1049 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
1050 )
1051 .unwrap();
1052
1053 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
1054 let record = initial_record(&pswap, 100, 50);
1055
1056 let att = PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
1057 let genuine_payback = pswap.payback_note(consumer, &att).unwrap();
1058 let forged = forged_note(
1061 Note::from(pswap.clone()).id(),
1062 att,
1063 genuine_payback.metadata().tag(),
1064 consumer,
1065 7,
1066 );
1067
1068 assert!(matches!(
1070 record.build_round_update(1, &[&forged], &no_block_headers(), Some(&pswap), false,),
1071 Ok(None)
1072 ));
1073
1074 let reclaim = expect_round(record.build_round_update(
1076 1,
1077 &[&forged],
1078 &no_block_headers(),
1079 Some(&pswap),
1080 true,
1081 ));
1082 assert_eq!(reclaim.state, PswapLineageState::Reclaimed);
1083 }
1084
1085 #[test]
1088 fn build_round_update_forged_remainder_yields_full_fill() {
1089 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
1090 let consumer = AccountId::try_from(
1091 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1092 )
1093 .unwrap();
1094 let creator = AccountId::try_from(
1095 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
1096 )
1097 .unwrap();
1098
1099 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
1100 let record = initial_record(&pswap, 100, 50);
1101
1102 let payback_att =
1103 PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
1104 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
1105 let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
1106
1107 let remainder_att =
1109 PswapNoteAttachment::new(AssetAmount::new(40).unwrap(), pswap.order_id(), 1);
1110 let genuine_remainder = pswap
1111 .remainder_note(
1112 consumer,
1113 &remainder_att,
1114 AssetAmount::new(60).unwrap(),
1115 AssetAmount::new(30).unwrap(),
1116 )
1117 .unwrap();
1118 let forged_remainder = forged_note(
1119 Note::from(pswap.clone()).id(),
1120 remainder_att,
1121 genuine_remainder.metadata().tag(),
1122 consumer,
1123 7,
1124 );
1125
1126 let update = expect_round(record.build_round_update(
1127 1,
1128 &[&cand_payback, &forged_remainder],
1129 &no_block_headers(),
1130 Some(&pswap),
1131 true,
1132 ));
1133 assert_eq!(
1134 update.state,
1135 PswapLineageState::FullyFilled,
1136 "forged remainder filtered → full fill"
1137 );
1138 assert!(update.remainder.is_none());
1139 }
1140
1141 #[test]
1144 fn build_round_update_bucket_padding_stays_partial() {
1145 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
1146 let consumer = AccountId::try_from(
1147 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1148 )
1149 .unwrap();
1150 let creator = AccountId::try_from(
1151 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
1152 )
1153 .unwrap();
1154
1155 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
1156 let record = initial_record(&pswap, 100, 50);
1157
1158 let payback_att =
1159 PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
1160 let remainder_att =
1161 PswapNoteAttachment::new(AssetAmount::new(40).unwrap(), pswap.order_id(), 1);
1162 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
1163 let remainder = pswap
1164 .remainder_note(
1165 consumer,
1166 &remainder_att,
1167 AssetAmount::new(60).unwrap(),
1168 AssetAmount::new(30).unwrap(),
1169 )
1170 .unwrap();
1171 let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
1172 let cand_remainder = chain_update_from(&remainder, remainder_att, consumer, 7);
1173 let forged = forged_note(
1175 Note::from(pswap.clone()).id(),
1176 payback_att,
1177 payback.metadata().tag(),
1178 consumer,
1179 7,
1180 );
1181
1182 let update = expect_round(record.build_round_update(
1183 1,
1184 &[&forged, &cand_payback, &cand_remainder],
1185 &no_block_headers(),
1186 Some(&pswap),
1187 true,
1188 ));
1189 assert_eq!(
1190 update.state,
1191 PswapLineageState::Active,
1192 "forgery dropped → still a partial fill"
1193 );
1194 assert_eq!(update.tip_note_id, Some(remainder.id()));
1195 }
1196}