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> {
42 match value {
43 0 => Ok(Self::Active),
44 1 => Ok(Self::FullyFilled),
45 2 => Ok(Self::Reclaimed),
46 other => Err(PswapLineageError::UnknownState(other)),
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
62pub struct PswapLineageRecord {
63 pub original_note_id: NoteId,
67
68 order_id: Felt,
71 creator_account_id: AccountId,
72
73 pub current_tip_note_id: NoteId,
76 pub current_depth: u32,
78 pub remaining_offered: AssetAmount,
81 pub remaining_requested: AssetAmount,
83 pub state: PswapLineageState,
84}
85
86impl PswapLineageRecord {
87 pub fn new_depth_zero(original_note_id: NoteId, pswap: &PswapNote) -> Self {
92 Self {
93 original_note_id,
94 order_id: pswap.order_id(),
95 creator_account_id: pswap.storage().creator_account_id(),
96 current_tip_note_id: original_note_id,
97 current_depth: 0,
98 remaining_offered: pswap.offered_asset().amount(),
99 remaining_requested: pswap.storage().requested_asset().amount(),
100 state: PswapLineageState::Active,
101 }
102 }
103
104 pub fn order_id(&self) -> Felt {
107 self.order_id
108 }
109
110 pub fn creator_account_id(&self) -> AccountId {
112 self.creator_account_id
113 }
114}
115
116#[derive(Debug, Clone)]
122pub(crate) struct PswapLineageRoundUpdate {
123 pub order_id: Felt,
124 pub round_depth: u32,
125 pub remaining_offered: AssetAmount,
127 pub remaining_requested: AssetAmount,
128 pub state: PswapLineageState,
129 pub tip_note_id: Option<NoteId>,
131 pub at_block_note_root: Option<Word>,
134 pub payback: Option<(Note, NoteInclusionProof)>,
138 pub remainder: Option<(Note, NoteInclusionProof)>,
141}
142
143#[derive(Debug, Clone)]
150pub(crate) struct ObservedPswapNote {
151 pub note_id: NoteId,
152 pub attachment: PswapNoteAttachment,
153 pub sender: AccountId,
154 pub tag: NoteTag,
156 pub block_num: BlockNumber,
157 pub inclusion_proof: NoteInclusionProof,
158}
159
160impl PswapLineageRecord {
164 pub(crate) fn build_round_update(
176 &self,
177 round_depth: u32,
178 notes: &[&ObservedPswapNote],
179 block_headers: &BTreeMap<BlockNumber, BlockHeader>,
180 original_pswap: Option<&PswapNote>,
181 tip_consumed: bool,
182 ) -> Result<Option<PswapLineageRoundUpdate>, PswapLineageError> {
183 if notes.is_empty() {
185 return Ok(Some(self.build_reclaim_round(round_depth)));
186 }
187
188 let pswap = original_pswap
190 .ok_or(PswapLineageError::OriginalNoteUnavailable(self.original_note_id))?;
191 let payback_tag = pswap.storage().payback_note_tag();
192
193 let Some((observed_payback, payback_note)) = notes
197 .iter()
198 .copied()
199 .filter(|note| note.tag == payback_tag)
200 .find_map(|note| validate_payback(pswap, note).map(|recon| (note, recon)))
201 else {
202 return Ok(tip_consumed.then(|| self.build_reclaim_round(round_depth)));
205 };
206
207 let fill_amount = observed_payback.attachment.amount();
209
210 let remainder =
213 notes.iter().copied().filter(|note| note.tag != payback_tag).find_map(|note| {
214 self.validate_remainder(pswap, note, fill_amount).map(|recon| (note, recon))
215 });
216
217 Ok(Some(match remainder {
218 Some((observed_remainder, remainder_note)) => self.build_partial_fill_round(
219 round_depth,
220 observed_payback,
221 payback_note,
222 observed_remainder,
223 remainder_note,
224 fill_amount,
225 block_headers,
226 ),
227 None => self.build_full_fill_round(
228 round_depth,
229 observed_payback,
230 payback_note,
231 block_headers,
232 ),
233 }))
234 }
235
236 fn validate_remainder(
240 &self,
241 pswap: &PswapNote,
242 observed: &ObservedPswapNote,
243 fill_amount: AssetAmount,
244 ) -> Option<Note> {
245 let payout_amount = observed.attachment.amount();
246 let (remaining_offered, remaining_requested) =
247 self.remaining_after_fill(fill_amount, payout_amount);
248 let remainder_note = pswap
249 .remainder_note(
250 observed.sender,
251 &observed.attachment,
252 remaining_offered,
253 remaining_requested,
254 )
255 .ok()?;
256 (remainder_note.id() == observed.note_id).then_some(remainder_note)
257 }
258
259 fn remaining_after_fill(
262 &self,
263 fill_amount: AssetAmount,
264 payout_amount: AssetAmount,
265 ) -> (AssetAmount, AssetAmount) {
266 (
267 saturating_sub(self.remaining_offered, payout_amount),
268 saturating_sub(self.remaining_requested, fill_amount),
269 )
270 }
271
272 fn build_reclaim_round(&self, round_depth: u32) -> PswapLineageRoundUpdate {
274 PswapLineageRoundUpdate {
275 order_id: self.order_id(),
276 round_depth,
277 remaining_offered: AssetAmount::ZERO,
278 remaining_requested: AssetAmount::ZERO,
279 state: PswapLineageState::Reclaimed,
280 tip_note_id: None,
281 at_block_note_root: None,
282 payback: None,
283 remainder: None,
284 }
285 }
286
287 fn build_full_fill_round(
290 &self,
291 round_depth: u32,
292 observed_payback: &ObservedPswapNote,
293 payback_note: Note,
294 block_headers: &BTreeMap<BlockNumber, BlockHeader>,
295 ) -> PswapLineageRoundUpdate {
296 PswapLineageRoundUpdate {
297 order_id: self.order_id(),
298 round_depth,
299 remaining_offered: AssetAmount::ZERO,
300 remaining_requested: AssetAmount::ZERO,
301 state: PswapLineageState::FullyFilled,
302 tip_note_id: None,
303 at_block_note_root: block_headers
304 .get(&observed_payback.block_num)
305 .map(BlockHeader::note_root),
306 payback: Some((payback_note, observed_payback.inclusion_proof.clone())),
307 remainder: None,
308 }
309 }
310
311 #[allow(clippy::too_many_arguments)]
314 fn build_partial_fill_round(
315 &self,
316 round_depth: u32,
317 observed_payback: &ObservedPswapNote,
318 payback_note: Note,
319 observed_remainder: &ObservedPswapNote,
320 remainder_note: Note,
321 fill_amount: AssetAmount,
322 block_headers: &BTreeMap<BlockNumber, BlockHeader>,
323 ) -> PswapLineageRoundUpdate {
324 let payout_amount = observed_remainder.attachment.amount();
325 let (remaining_offered, remaining_requested) =
326 self.remaining_after_fill(fill_amount, payout_amount);
327
328 PswapLineageRoundUpdate {
329 order_id: self.order_id(),
330 round_depth,
331 remaining_offered,
332 remaining_requested,
333 state: PswapLineageState::Active,
334 tip_note_id: Some(observed_remainder.note_id),
335 at_block_note_root: block_headers
336 .get(&observed_payback.block_num)
337 .map(BlockHeader::note_root),
338 payback: Some((payback_note, observed_payback.inclusion_proof.clone())),
339 remainder: Some((remainder_note, observed_remainder.inclusion_proof.clone())),
340 }
341 }
342
343 pub(crate) fn advance(mut self, update: &PswapLineageRoundUpdate) -> PswapLineageRecord {
346 self.current_depth = update.round_depth;
347 self.remaining_offered = update.remaining_offered;
348 self.remaining_requested = update.remaining_requested;
349 self.state = update.state;
350 if let Some(note_id) = update.tip_note_id {
351 self.current_tip_note_id = note_id;
352 }
353 self
354 }
355}
356
357fn validate_payback(pswap: &PswapNote, observed: &ObservedPswapNote) -> Option<Note> {
360 let payback_note = pswap.payback_note(observed.sender, &observed.attachment).ok()?;
361 (payback_note.id() == observed.note_id).then_some(payback_note)
362}
363
364fn saturating_sub(total: AssetAmount, used: AssetAmount) -> AssetAmount {
366 AssetAmount::new(total.as_u64().saturating_sub(used.as_u64()))
367 .expect("a value <= an existing AssetAmount is itself a valid AssetAmount")
368}
369
370#[derive(Debug, Clone)]
376pub(crate) enum PswapLineageFilter {
377 All,
378 Active,
379 ByCreator(AccountId),
380}
381
382#[allow(clippy::too_many_arguments)]
389pub(crate) fn build_record_from_fields(
390 original_note_id: NoteId,
391 order_id: Felt,
392 creator_account_id: AccountId,
393 current_tip_note_id: NoteId,
394 current_depth: u32,
395 remaining_offered: AssetAmount,
396 remaining_requested: AssetAmount,
397 state_byte: u8,
398) -> Result<PswapLineageRecord, PswapLineageError> {
399 Ok(PswapLineageRecord {
400 original_note_id,
401 order_id,
402 creator_account_id,
403 current_tip_note_id,
404 current_depth,
405 remaining_offered,
406 remaining_requested,
407 state: PswapLineageState::try_from_u8(state_byte)?,
408 })
409}
410
411impl Serializable for PswapLineageRecord {
419 fn write_into<W: ByteWriter>(&self, target: &mut W) {
420 self.original_note_id.write_into(target);
421 self.order_id.write_into(target);
422 self.creator_account_id.write_into(target);
423 self.current_tip_note_id.write_into(target);
424 self.current_depth.write_into(target);
425 self.remaining_offered.write_into(target);
426 self.remaining_requested.write_into(target);
427 self.state.as_u8().write_into(target);
428 }
429}
430
431impl Deserializable for PswapLineageRecord {
432 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
433 let original_note_id = NoteId::read_from(source)?;
434 let order_id = Felt::read_from(source)?;
435 let creator_account_id = AccountId::read_from(source)?;
436 let current_tip_note_id = NoteId::read_from(source)?;
437 let current_depth = u32::read_from(source)?;
438 let remaining_offered = AssetAmount::read_from(source)?;
439 let remaining_requested = AssetAmount::read_from(source)?;
440 let state_byte = u8::read_from(source)?;
441 build_record_from_fields(
442 original_note_id,
443 order_id,
444 creator_account_id,
445 current_tip_note_id,
446 current_depth,
447 remaining_offered,
448 remaining_requested,
449 state_byte,
450 )
451 .map_err(|err| DeserializationError::InvalidValue(err.to_string()))
452 }
453}
454
455#[cfg(test)]
456pub(crate) mod test_helpers {
457 use miden_protocol::Word;
460 use miden_protocol::account::AccountId;
461 use miden_protocol::asset::FungibleAsset;
462 use miden_protocol::note::NoteType;
463 use miden_protocol::testing::account_id::{
464 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
465 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1,
466 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
467 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
468 };
469 use miden_standards::note::{PswapNote, PswapNoteStorage};
470
471 pub fn fixed_account_ids() -> (AccountId, AccountId, AccountId, AccountId) {
475 (
476 AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(),
477 AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2).unwrap(),
478 AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(),
479 AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).unwrap(),
480 )
481 }
482
483 pub fn build_test_pswap(
487 sender: AccountId,
488 creator: AccountId,
489 offered_faucet: AccountId,
490 offered_amount: u64,
491 requested_faucet: AccountId,
492 requested_amount: u64,
493 ) -> PswapNote {
494 let offered = FungibleAsset::new(offered_faucet, offered_amount).unwrap();
495 let requested = FungibleAsset::new(requested_faucet, requested_amount).unwrap();
496 let storage = PswapNoteStorage::builder()
497 .requested_asset(requested)
498 .creator_account_id(creator)
499 .build();
500 PswapNote::builder()
501 .sender(sender)
502 .storage(storage)
503 .serial_number(Word::from([
504 miden_protocol::Felt::new(1).unwrap(),
505 miden_protocol::Felt::new(2).unwrap(),
506 miden_protocol::Felt::new(3).unwrap(),
507 miden_protocol::Felt::new(4).unwrap(),
508 ]))
509 .note_type(NoteType::Public)
510 .offered_asset(offered)
511 .build()
512 .unwrap()
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use alloc::vec::Vec;
519
520 use miden_protocol::asset::AssetAmount;
521 use miden_protocol::crypto::merkle::SparseMerklePath;
522 use miden_standards::note::PswapNote;
523
524 use super::test_helpers::{build_test_pswap, fixed_account_ids};
525 use super::*;
526
527 fn record_from_test_pswap(
531 pswap: &PswapNote,
532 current_tip_note_id: NoteId,
533 current_depth: u32,
534 remaining_offered: u64,
535 remaining_requested: u64,
536 state_byte: u8,
537 ) -> Result<PswapLineageRecord, PswapLineageError> {
538 let original_note_id = miden_protocol::note::Note::from(pswap.clone()).id();
539 build_record_from_fields(
540 original_note_id,
541 pswap.order_id(),
542 pswap.storage().creator_account_id(),
543 current_tip_note_id,
544 current_depth,
545 AssetAmount::new(remaining_offered).unwrap(),
546 AssetAmount::new(remaining_requested).unwrap(),
547 state_byte,
548 )
549 }
550
551 #[test]
555 fn state_byte_encoding_is_stable() {
556 assert_eq!(PswapLineageState::Active.as_u8(), 0);
557 assert_eq!(PswapLineageState::FullyFilled.as_u8(), 1);
558 assert_eq!(PswapLineageState::Reclaimed.as_u8(), 2);
559 }
560
561 #[test]
564 fn state_try_from_u8_round_trips_known_variants() {
565 for state in [
566 PswapLineageState::Active,
567 PswapLineageState::FullyFilled,
568 PswapLineageState::Reclaimed,
569 ] {
570 assert_eq!(PswapLineageState::try_from_u8(state.as_u8()).unwrap(), state);
571 }
572 }
573
574 #[test]
577 fn state_try_from_u8_rejects_unknown() {
578 match PswapLineageState::try_from_u8(99) {
579 Err(PswapLineageError::UnknownState(99)) => {},
580 other => panic!("expected UnknownState(99), got {other:?}"),
581 }
582 }
583
584 #[test]
586 fn build_record_from_fields_accepts_valid_depth_zero_record() {
587 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
588 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
589 let initial_note_id = miden_protocol::note::Note::from(pswap.clone()).id();
590
591 let record = record_from_test_pswap(
592 &pswap,
593 initial_note_id,
594 0,
595 100,
596 50,
597 PswapLineageState::Active.as_u8(),
598 )
599 .unwrap();
600
601 assert_eq!(record.current_depth, 0);
602 assert_eq!(record.remaining_offered, AssetAmount::new(100).unwrap());
603 assert_eq!(record.remaining_requested, AssetAmount::new(50).unwrap());
604 assert_eq!(record.state, PswapLineageState::Active);
605 }
606
607 #[test]
609 fn build_record_from_fields_accepts_valid_advanced_record() {
610 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
611 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
612 let note = miden_protocol::note::Note::from(pswap.clone());
613 let record =
614 record_from_test_pswap(&pswap, note.id(), 3, 70, 35, PswapLineageState::Active.as_u8())
615 .unwrap();
616
617 assert_eq!(record.current_depth, 3);
618 assert_eq!(record.remaining_offered, AssetAmount::new(70).unwrap());
619 }
620
621 #[test]
623 fn build_record_from_fields_rejects_unknown_state() {
624 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
625 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
626 let note = miden_protocol::note::Note::from(pswap.clone());
627 match record_from_test_pswap(&pswap, note.id(), 0, 100, 50, 42) {
628 Err(PswapLineageError::UnknownState(42)) => {},
629 other => panic!("expected UnknownState(42), got {other:?}"),
630 }
631 }
632
633 #[test]
636 fn accessors_mirror_depth_zero_note() {
637 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
638 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
639
640 let expected_order_id = pswap.order_id();
641
642 let note = miden_protocol::note::Note::from(pswap.clone());
643 let record = record_from_test_pswap(
644 &pswap,
645 note.id(),
646 0,
647 100,
648 50,
649 PswapLineageState::Active.as_u8(),
650 )
651 .unwrap();
652
653 assert_eq!(record.original_note_id, note.id());
654 assert_eq!(record.order_id(), expected_order_id);
655 assert_eq!(record.creator_account_id(), creator);
656 }
657
658 #[test]
661 fn value_codec_round_trips() {
662 let (sender, creator, offered_faucet, requested_faucet) = fixed_account_ids();
663 let pswap = build_test_pswap(sender, creator, offered_faucet, 100, requested_faucet, 50);
664 let note = miden_protocol::note::Note::from(pswap.clone());
665 let record =
666 record_from_test_pswap(&pswap, note.id(), 3, 70, 35, PswapLineageState::Active.as_u8())
667 .unwrap();
668
669 let bytes = record.to_bytes();
670 let decoded = PswapLineageRecord::read_from_bytes(&bytes).unwrap();
671
672 assert_eq!(decoded.original_note_id, record.original_note_id);
673 assert_eq!(decoded.creator_account_id(), record.creator_account_id());
674 assert_eq!(decoded.order_id(), record.order_id());
675 assert_eq!(decoded.current_tip_note_id, record.current_tip_note_id);
676 assert_eq!(decoded.current_depth, record.current_depth);
677 assert_eq!(decoded.remaining_offered, record.remaining_offered);
678 assert_eq!(decoded.remaining_requested, record.remaining_requested);
679 assert_eq!(decoded.remaining_offered, AssetAmount::new(70).unwrap());
680 assert_eq!(decoded.remaining_requested, AssetAmount::new(35).unwrap());
681 assert_eq!(decoded.state, record.state);
682 }
683
684 fn dummy_inclusion_proof(block: u32) -> NoteInclusionProof {
689 let path =
690 SparseMerklePath::from_parts(0, Vec::new()).expect("empty SparseMerklePath is valid");
691 NoteInclusionProof::new(BlockNumber::from(block), 0, path)
692 .expect("zero index is well below the per-block notes ceiling")
693 }
694
695 fn no_block_headers() -> BTreeMap<BlockNumber, BlockHeader> {
697 BTreeMap::new()
698 }
699
700 fn initial_record(pswap: &PswapNote, offered: u64, requested: u64) -> PswapLineageRecord {
702 let original_note_id = Note::from(pswap.clone()).id();
703 let mut record = PswapLineageRecord::new_depth_zero(original_note_id, pswap);
704 record.remaining_offered =
706 AssetAmount::new(offered).expect("test value fits in AssetAmount");
707 record.remaining_requested =
708 AssetAmount::new(requested).expect("test value fits in AssetAmount");
709 record
710 }
711
712 fn chain_update_from(
715 note: &Note,
716 attachment: PswapNoteAttachment,
717 sender: AccountId,
718 block: u32,
719 ) -> ObservedPswapNote {
720 ObservedPswapNote {
721 note_id: note.id(),
722 attachment,
723 sender,
724 tag: note.metadata().tag(),
725 block_num: BlockNumber::from(block),
726 inclusion_proof: dummy_inclusion_proof(block),
727 }
728 }
729
730 fn forged_note(
733 forged_id: NoteId,
734 attachment: PswapNoteAttachment,
735 tag: NoteTag,
736 sender: AccountId,
737 block: u32,
738 ) -> ObservedPswapNote {
739 ObservedPswapNote {
740 note_id: forged_id,
741 attachment,
742 sender,
743 tag,
744 block_num: BlockNumber::from(block),
745 inclusion_proof: dummy_inclusion_proof(block),
746 }
747 }
748
749 fn expect_round(
751 result: Result<Option<PswapLineageRoundUpdate>, PswapLineageError>,
752 ) -> PswapLineageRoundUpdate {
753 result
754 .expect("build_round_update should not error")
755 .expect("expected a round update")
756 }
757
758 #[test]
760 fn build_round_update_partial_fill_advances_active() {
761 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
762 let consumer = AccountId::try_from(
763 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
764 )
765 .unwrap();
766 let creator = AccountId::try_from(
767 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
768 )
769 .unwrap();
770
771 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
772 let record = initial_record(&pswap, 100, 50);
773
774 let fill_amount = 20;
775 let payout_amount = 40;
776 let new_off = 100 - payout_amount;
777 let new_req = 50 - fill_amount;
778
779 let payback_att =
780 PswapNoteAttachment::new(AssetAmount::new(fill_amount).unwrap(), pswap.order_id(), 1);
781 let remainder_att =
782 PswapNoteAttachment::new(AssetAmount::new(payout_amount).unwrap(), pswap.order_id(), 1);
783 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
784 let remainder = pswap
785 .remainder_note(
786 consumer,
787 &remainder_att,
788 AssetAmount::new(new_off).unwrap(),
789 AssetAmount::new(new_req).unwrap(),
790 )
791 .unwrap();
792
793 let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
794 let cand_remainder = chain_update_from(&remainder, remainder_att, consumer, 7);
795
796 let update = expect_round(record.build_round_update(
797 1,
798 &[&cand_payback, &cand_remainder],
799 &no_block_headers(),
800 Some(&pswap),
801 true,
802 ));
803
804 assert_eq!(update.round_depth, 1);
805 assert_eq!(update.remaining_offered, AssetAmount::new(new_off).unwrap());
806 assert_eq!(update.remaining_requested, AssetAmount::new(new_req).unwrap());
807 assert_eq!(update.state, PswapLineageState::Active);
808 assert_eq!(update.tip_note_id, Some(remainder.id()));
809 assert!(update.payback.is_some());
811 assert!(update.remainder.is_some());
812 }
813
814 #[test]
818 fn build_round_update_partial_fill_classifies_regardless_of_note_order() {
819 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
820 let consumer = AccountId::try_from(
821 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
822 )
823 .unwrap();
824 let creator = AccountId::try_from(
825 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
826 )
827 .unwrap();
828
829 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
830 let record = initial_record(&pswap, 100, 50);
831
832 let fill_amount = 20;
833 let payout_amount = 40;
834 let new_off = 100 - payout_amount;
835 let new_req = 50 - fill_amount;
836
837 let payback_att =
838 PswapNoteAttachment::new(AssetAmount::new(fill_amount).unwrap(), pswap.order_id(), 1);
839 let remainder_att =
840 PswapNoteAttachment::new(AssetAmount::new(payout_amount).unwrap(), pswap.order_id(), 1);
841 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
842 let remainder = pswap
843 .remainder_note(
844 consumer,
845 &remainder_att,
846 AssetAmount::new(new_off).unwrap(),
847 AssetAmount::new(new_req).unwrap(),
848 )
849 .unwrap();
850
851 let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
852 let cand_remainder = chain_update_from(&remainder, remainder_att, consumer, 7);
853
854 let update = expect_round(record.build_round_update(
856 1,
857 &[&cand_remainder, &cand_payback],
858 &no_block_headers(),
859 Some(&pswap),
860 true,
861 ));
862
863 assert_eq!(update.tip_note_id, Some(remainder.id()));
864 assert_eq!(update.state, PswapLineageState::Active);
865 }
866
867 #[test]
872 fn build_round_update_filters_unreconstructable_candidate() {
873 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
874 let consumer = AccountId::try_from(
875 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
876 )
877 .unwrap();
878 let creator = AccountId::try_from(
879 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
880 )
881 .unwrap();
882
883 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
884 let record = initial_record(&pswap, 100, 50);
885
886 let good_att = PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
890 let payback = pswap.payback_note(consumer, &good_att).unwrap();
891 let bad_att = PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 0);
892 let cand = forged_note(payback.id(), bad_att, payback.metadata().tag(), consumer, 5);
893
894 let result =
896 record.build_round_update(1, &[&cand], &no_block_headers(), Some(&pswap), false);
897 assert!(
898 matches!(result, Ok(None)),
899 "unreconstructable candidate must be filtered, not fatal"
900 );
901 }
902
903 #[test]
905 fn build_round_update_full_fill_marks_fully_filled() {
906 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
907 let consumer = AccountId::try_from(
908 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
909 )
910 .unwrap();
911 let creator = AccountId::try_from(
912 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
913 )
914 .unwrap();
915
916 let pswap = build_test_pswap(consumer, creator, offered_faucet, 30, requested_faucet, 50);
918 let record = initial_record(&pswap, 30, 50);
919
920 let fill_amount = 50; let payback_att =
922 PswapNoteAttachment::new(AssetAmount::new(fill_amount).unwrap(), pswap.order_id(), 1);
923 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
924 let cand = chain_update_from(&payback, payback_att, consumer, 9);
925
926 let update = expect_round(record.build_round_update(
927 1,
928 &[&cand],
929 &no_block_headers(),
930 Some(&pswap),
931 true,
932 ));
933
934 assert_eq!(update.state, PswapLineageState::FullyFilled);
935 assert_eq!(update.remaining_offered, AssetAmount::ZERO);
936 assert_eq!(update.remaining_requested, AssetAmount::ZERO);
937 assert_eq!(update.tip_note_id, None);
938 assert!(update.remainder.is_none());
939 }
940
941 #[test]
944 fn build_round_update_zero_outputs_marks_reclaimed_with_remaining_zero() {
945 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
946 let consumer = AccountId::try_from(
947 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
948 )
949 .unwrap();
950 let creator = AccountId::try_from(
951 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
952 )
953 .unwrap();
954
955 let pswap = build_test_pswap(consumer, creator, offered_faucet, 80, requested_faucet, 40);
956 let record = initial_record(&pswap, 80, 40);
957
958 let update =
959 expect_round(record.build_round_update(1, &[], &no_block_headers(), None, true));
960
961 assert_eq!(update.state, PswapLineageState::Reclaimed);
962 assert_eq!(update.remaining_offered, AssetAmount::ZERO);
963 assert_eq!(update.remaining_requested, AssetAmount::ZERO);
966 assert!(update.payback.is_none());
967 }
968
969 #[test]
972 fn advance_chains_correctly_for_multi_fill() {
973 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
974 let consumer = AccountId::try_from(
975 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
976 )
977 .unwrap();
978 let creator = AccountId::try_from(
979 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
980 )
981 .unwrap();
982
983 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
984 let record0 = initial_record(&pswap, 100, 50);
985
986 let fill1 = 20;
988 let payout1 = 40;
989 let new_off1 = 100 - payout1;
990 let new_req1 = 50 - fill1;
991 let payback_att1 =
992 PswapNoteAttachment::new(AssetAmount::new(fill1).unwrap(), pswap.order_id(), 1);
993 let remainder_att1 =
994 PswapNoteAttachment::new(AssetAmount::new(payout1).unwrap(), pswap.order_id(), 1);
995 let payback1 = pswap.payback_note(consumer, &payback_att1).unwrap();
996 let remainder1 = pswap
997 .remainder_note(
998 consumer,
999 &remainder_att1,
1000 AssetAmount::new(new_off1).unwrap(),
1001 AssetAmount::new(new_req1).unwrap(),
1002 )
1003 .unwrap();
1004 let payback_cand = chain_update_from(&payback1, payback_att1, consumer, 11);
1005 let remainder_cand = chain_update_from(&remainder1, remainder_att1, consumer, 11);
1006
1007 let update1 = expect_round(record0.build_round_update(
1008 1,
1009 &[&payback_cand, &remainder_cand],
1010 &no_block_headers(),
1011 Some(&pswap),
1012 true,
1013 ));
1014
1015 let record1 = record0.advance(&update1);
1017 assert_eq!(record1.current_depth, 1);
1018 assert_eq!(record1.remaining_offered, AssetAmount::new(new_off1).unwrap());
1019 assert_eq!(record1.remaining_requested, AssetAmount::new(new_req1).unwrap());
1020 assert_eq!(record1.current_tip_note_id, remainder1.id());
1021 assert_eq!(record1.state, PswapLineageState::Active);
1022
1023 let fill2 = new_req1; let payback_att2 =
1026 PswapNoteAttachment::new(AssetAmount::new(fill2).unwrap(), pswap.order_id(), 2);
1027 let payback2 = pswap.payback_note(consumer, &payback_att2).unwrap();
1028 let cand_p2 = chain_update_from(&payback2, payback_att2, consumer, 11);
1029
1030 let update2 = expect_round(record1.build_round_update(
1031 2,
1032 &[&cand_p2],
1033 &no_block_headers(),
1034 Some(&pswap),
1035 true,
1036 ));
1037
1038 assert_eq!(update2.round_depth, 2);
1039 assert_eq!(update2.state, PswapLineageState::FullyFilled);
1040 assert_eq!(update2.remaining_offered, AssetAmount::ZERO);
1041 assert_eq!(update2.remaining_requested, AssetAmount::ZERO);
1042
1043 let record2 = record1.advance(&update2);
1044 assert_eq!(record2.state, PswapLineageState::FullyFilled);
1045 let emitted = [update1, update2];
1046 assert_eq!(emitted.len(), 2);
1047 }
1048
1049 #[test]
1053 fn build_round_update_filters_forged_payback() {
1054 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
1055 let consumer = AccountId::try_from(
1056 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1057 )
1058 .unwrap();
1059 let creator = AccountId::try_from(
1060 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
1061 )
1062 .unwrap();
1063
1064 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
1065 let record = initial_record(&pswap, 100, 50);
1066
1067 let att = PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
1068 let genuine_payback = pswap.payback_note(consumer, &att).unwrap();
1069 let forged = forged_note(
1072 Note::from(pswap.clone()).id(),
1073 att,
1074 genuine_payback.metadata().tag(),
1075 consumer,
1076 7,
1077 );
1078
1079 assert!(matches!(
1081 record.build_round_update(1, &[&forged], &no_block_headers(), Some(&pswap), false,),
1082 Ok(None)
1083 ));
1084
1085 let reclaim = expect_round(record.build_round_update(
1087 1,
1088 &[&forged],
1089 &no_block_headers(),
1090 Some(&pswap),
1091 true,
1092 ));
1093 assert_eq!(reclaim.state, PswapLineageState::Reclaimed);
1094 }
1095
1096 #[test]
1099 fn build_round_update_forged_remainder_yields_full_fill() {
1100 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
1101 let consumer = AccountId::try_from(
1102 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1103 )
1104 .unwrap();
1105 let creator = AccountId::try_from(
1106 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
1107 )
1108 .unwrap();
1109
1110 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
1111 let record = initial_record(&pswap, 100, 50);
1112
1113 let payback_att =
1114 PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
1115 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
1116 let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
1117
1118 let remainder_att =
1120 PswapNoteAttachment::new(AssetAmount::new(40).unwrap(), pswap.order_id(), 1);
1121 let genuine_remainder = pswap
1122 .remainder_note(
1123 consumer,
1124 &remainder_att,
1125 AssetAmount::new(60).unwrap(),
1126 AssetAmount::new(30).unwrap(),
1127 )
1128 .unwrap();
1129 let forged_remainder = forged_note(
1130 Note::from(pswap.clone()).id(),
1131 remainder_att,
1132 genuine_remainder.metadata().tag(),
1133 consumer,
1134 7,
1135 );
1136
1137 let update = expect_round(record.build_round_update(
1138 1,
1139 &[&cand_payback, &forged_remainder],
1140 &no_block_headers(),
1141 Some(&pswap),
1142 true,
1143 ));
1144 assert_eq!(
1145 update.state,
1146 PswapLineageState::FullyFilled,
1147 "forged remainder filtered → full fill"
1148 );
1149 assert!(update.remainder.is_none());
1150 }
1151
1152 #[test]
1155 fn build_round_update_bucket_padding_stays_partial() {
1156 let (_sender, _creator, offered_faucet, requested_faucet) = fixed_account_ids();
1157 let consumer = AccountId::try_from(
1158 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1159 )
1160 .unwrap();
1161 let creator = AccountId::try_from(
1162 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2,
1163 )
1164 .unwrap();
1165
1166 let pswap = build_test_pswap(consumer, creator, offered_faucet, 100, requested_faucet, 50);
1167 let record = initial_record(&pswap, 100, 50);
1168
1169 let payback_att =
1170 PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
1171 let remainder_att =
1172 PswapNoteAttachment::new(AssetAmount::new(40).unwrap(), pswap.order_id(), 1);
1173 let payback = pswap.payback_note(consumer, &payback_att).unwrap();
1174 let remainder = pswap
1175 .remainder_note(
1176 consumer,
1177 &remainder_att,
1178 AssetAmount::new(60).unwrap(),
1179 AssetAmount::new(30).unwrap(),
1180 )
1181 .unwrap();
1182 let cand_payback = chain_update_from(&payback, payback_att, consumer, 7);
1183 let cand_remainder = chain_update_from(&remainder, remainder_att, consumer, 7);
1184 let forged = forged_note(
1186 Note::from(pswap.clone()).id(),
1187 payback_att,
1188 payback.metadata().tag(),
1189 consumer,
1190 7,
1191 );
1192
1193 let update = expect_round(record.build_round_update(
1194 1,
1195 &[&forged, &cand_payback, &cand_remainder],
1196 &no_block_headers(),
1197 Some(&pswap),
1198 true,
1199 ));
1200 assert_eq!(
1201 update.state,
1202 PswapLineageState::Active,
1203 "forgery dropped → still a partial fill"
1204 );
1205 assert_eq!(update.tip_note_id, Some(remainder.id()));
1206 }
1207}