Skip to main content

miden_client/pswap/
lineage.rs

1//! Persistent record and per-round transition types for one PSWAP order.
2//!
3//! See module-level docs on [`crate::pswap`].
4
5use 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// PSWAP LINEAGE STATE
19// ================================================================================================
20
21/// Lifecycle state of a PSWAP order. Discriminants are part of the serialized encoding — do not
22/// renumber.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[repr(u8)]
25pub enum PswapLineageState {
26    /// Still fillable / reclaimable.
27    Active = 0,
28    /// Fully filled. Terminal.
29    FullyFilled = 1,
30    /// Reclaimed by the creator. Terminal.
31    Reclaimed = 2,
32}
33
34impl PswapLineageState {
35    pub fn as_u8(self) -> u8 {
36        self as u8
37    }
38
39    /// Errors on unknown discriminants, guards against forward-incompatible serialized encodings.
40    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// PSWAP LINEAGE RECORD
51// ================================================================================================
52
53/// Persistent record of one PSWAP order's chain state. The order id and creator are mirrored here
54/// so the common read paths — keying, filtering — stay lookup-free. Only the remaining *amounts*
55/// are stored; the asset pair's faucets, the script/recipient, and the `note_type` all live on the
56/// depth-0 note, fetched on demand from `output_notes` by `original_note_id` (see
57/// `store::get_original_pswap`) when reconstruction, a depth-0 reclaim, or asset-pair-tag
58/// derivation needs them.
59#[derive(Debug, Clone)]
60pub struct PswapLineageRecord {
61    /// Fetch handle for the depth-0 PSWAP note in `output_notes`. Stable for the order's lifetime;
62    /// distinct from `current_tip_note_id`, which advances each round.
63    pub original_note_id: NoteId,
64
65    // Immutable order facts, mirrored from the depth-0 note so keying and filtering need no store
66    // lookup.
67    order_id: Felt,
68    creator_account_id: AccountId,
69
70    /// Current tip's note id. Equals `original_note_id` at depth 0; otherwise a remainder we didn't
71    /// originate.
72    pub current_tip_note_id: NoteId,
73    /// 0 for the original tip; +1 per round. Matches `PswapNoteAttachment::depth()`.
74    pub current_depth: u32,
75    /// Remaining offered amount. The order's offered faucet is chain-invariant and recovered from
76    /// the depth-0 note when needed (e.g. for tag derivation).
77    pub remaining_offered: AssetAmount,
78    /// Remaining requested amount (requested faucet recovered the same way).
79    pub remaining_requested: AssetAmount,
80    pub state: PswapLineageState,
81}
82
83impl PswapLineageRecord {
84    /// Builds the depth-0 record for a PSWAP the wallet has just emitted. Mirrors the order id and
85    /// creator off the note and seeds the mutable tip state: the tip is the original note, depth is
86    /// 0, and `remaining_*` equal the initial offered/requested amounts.
87    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    /// Stable identifier (== the depth-0 note's `serial[1]`) shared by every note in the chain.
101    pub fn order_id(&self) -> Felt {
102        self.order_id
103    }
104
105    /// Account that created the order (recipient of every payback).
106    pub fn creator_account_id(&self) -> AccountId {
107        self.creator_account_id
108    }
109}
110
111// PSWAP LINEAGE ROUND UPDATE
112// ================================================================================================
113
114/// One round's transition. Fill = payback + remainder (≤1 each); reclaim = no outputs. Applied
115/// atomically by `apply_round`.
116#[derive(Debug, Clone)]
117pub(crate) struct PswapLineageRoundUpdate {
118    pub order_id: Felt,
119    pub round_depth: u32,
120    // Post-round state — all fields below describe the lineage AFTER this round.
121    pub remaining_offered: AssetAmount,
122    pub remaining_requested: AssetAmount,
123    pub state: PswapLineageState,
124    /// New tip; `None` for terminal rounds.
125    pub tip_note_id: Option<NoteId>,
126    /// Commit block's note root, used by `apply_round` to insert payback/remainder as `Committed`.
127    /// `None` on reclaim rounds (no note to insert) and in store-tier fixtures.
128    pub at_block_note_root: Option<Word>,
129    /// Reconstructed payback and its inclusion proof. `None` only on reclaim. The note and proof
130    /// are always observed together in the same sync window, so they live or die as a pair.
131    pub payback: Option<(Note, NoteInclusionProof)>,
132    /// Reconstructed remainder and its inclusion proof. `None` on terminal rounds (full fill /
133    /// reclaim). Paired for the same reason as `payback`.
134    pub remainder: Option<(Note, NoteInclusionProof)>,
135}
136
137// OBSERVED CHAIN NOTE
138// ================================================================================================
139
140/// Observed PSWAP-attachment note. The typed attachment carries `order_id`, `depth`, and amount
141/// (fill on payback, payout on remainder) — role distinguished by [`Self::tag`].
142#[derive(Debug, Clone)]
143pub(crate) struct ObservedPswapNote {
144    pub note_id: NoteId,
145    pub attachment: PswapNoteAttachment,
146    pub sender: AccountId,
147    /// Payback uses the P2ID-style tag; remainder uses the asset-pair tag.
148    pub tag: NoteTag,
149    pub block_num: BlockNumber,
150    pub inclusion_proof: NoteInclusionProof,
151}
152
153// PER-ROUND CLASSIFICATION AND ADVANCE
154// ================================================================================================
155
156impl PswapLineageRecord {
157    /// Builds this round's [`PswapLineageRoundUpdate`] from the chain notes observed at
158    /// `round_depth`.
159    ///
160    /// The `(order_id, depth)` bucket is keyed off attachment fields the sender controls, so the
161    /// raw note set is untrusted. We **validate then classify**: each candidate is reconstructed
162    /// from our stored depth-0 note and kept only if its id matches the observed note (a forger
163    /// can't match without actually emitting a genuine payback/remainder of our order).
164    /// Classification runs on the surviving genuine notes, never on the raw count.
165    ///
166    /// Returns `Ok(None)` when the bucket holds no genuine note and the tip wasn't consumed — the
167    /// notes are forged/unrelated and this isn't our round, so the caller stops advancing.
168    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        // No outputs at all: the only way the round fired is a consumed tip → reclaim.
177        if notes.is_empty() {
178            return Ok(Some(self.build_reclaim_round(round_depth)));
179        }
180
181        // Fetched by the caller before any fill round; absence is a broken invariant.
182        let pswap = original_pswap
183            .ok_or(PswapLineageError::OriginalNoteUnavailable(self.original_note_id))?;
184        let payback_tag = pswap.storage().payback_note_tag();
185
186        // The genuine payback anchors the round; forged candidates reconstruct to a different id
187        // and fall away. More than one genuine payback at a depth is impossible (one tip → one
188        // fill), so the first match is the one.
189        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            // No valid fill: a consumed tip with no genuine payback is a reclaim; otherwise the
196            // notes are forged against a still-live tip and this isn't our round.
197            return Ok(tip_consumed.then(|| self.build_reclaim_round(round_depth)));
198        };
199
200        // Requested amount filled this round, read straight off the validated payback's attachment.
201        let fill_amount = observed_payback.attachment.amount();
202
203        // A genuine remainder (if any) is validated against the post-round balances derived from
204        // the payback's fill. Present → partial fill; absent → full fill.
205        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    /// The genuine remainder for `observed`, if it reconstructs (against the post-round balances
230    /// derived from `fill_amount` and the candidate's own payout) to a matching id; `None`
231    /// otherwise.
232    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    /// Saturating post-round balances after filling `fill_amount` (requested) for `payout_amount`
253    /// (offered). Clamps to zero on over-fill.
254    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    /// Reclaim — cancel branch emits no outputs; only the creator can hit it.
266    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    /// Full fill — only payback emitted; both `remaining_*` → 0. Takes the already-validated
281    /// `payback_note` and its observed note (for the inclusion proof and commit block).
282    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    /// Partial fill — payback + remainder. Takes the already-validated notes; balances come from
305    /// the payback's `fill_amount` and the remainder's payout.
306    #[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    /// Returns the post-round version of the record. Drives the same-block multi-fill loop in
337    /// `discovery`, and is reused by `store::apply_round` to compute the persisted advance.
338    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
350/// The genuine payback for `observed`, if it reconstructs to a matching id; `None` otherwise
351/// (forged, unrelated, or unreconstructable — all skipped, never trusted).
352fn 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
357/// `total - used`, clamped to zero — an over-fill can't drive a balance negative.
358fn 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// PSWAP LINEAGE FILTER
364// ================================================================================================
365
366/// Client-side filter for `crate::pswap::store::list_lineages`. Applied in Rust after a prefix-scan
367/// of the `settings` KV — not a store-trait concept.
368#[derive(Debug, Clone)]
369pub(crate) enum PswapLineageFilter {
370    All,
371    Active,
372    ByCreator(AccountId),
373}
374
375// SERDE HELPERS
376// ================================================================================================
377
378/// Builds a [`PswapLineageRecord`] from its decoded fields. Lives here (not in a store backend) so
379/// alternative backends can reuse it. The only validation is decoding the `state_byte` into a known
380/// [`PswapLineageState`].
381#[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
404// VALUE CODEC
405// ================================================================================================
406
407/// Encodes the record's fields in declaration order: the `original_note_id` fetch handle, the
408/// mirrored order id and creator, then the mutable tip state. Only the remaining *amounts* are
409/// written — the faucets and full note live on the depth-0 note, recovered via `original_note_id`
410/// when needed.
411impl 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    //! Synthetic-PSWAP factory shared across lineage / discovery / store tests.
451
452    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    /// Returns `(sender, creator, offered_faucet, requested_faucet)` — four distinct testing
465    /// `AccountId`s chosen to satisfy PSWAP's faucet-distinctness invariant.
466    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    /// Builds a fully-formed [`PswapNote`] for use in tests. Defaults: public note type, 100-unit
476    /// offered, 50-unit requested, serial number `[1, 2, 3, 4]`. Override via the params.
477    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    /// Builds a record from a test `PswapNote`, mirroring the immutable scalars the observer would
519    /// extract. Keeps the codec/accessor tests focused on the fields they exercise instead of the
520    /// new wide constructor signature.
521    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    /// Stable byte encoding of `PswapLineageState`. The values are persisted in the serialized
543    /// lineage record; reordering would silently corrupt existing stores.
544    #[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    /// Round-trip every state via `try_from_u8`. Belt-and-suspenders against a future renumbering
552    /// breaking the serialized format.
553    #[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    /// Unknown discriminants must error — defends against a future store reading a
565    /// forward-incompatible byte.
566    #[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    /// Happy path for `build_record_from_fields` at depth 0.
575    #[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    /// Happy path at `current_depth > 0`.
598    #[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    /// Unknown state discriminant in a stored record bubbles up as `UnknownState`.
612    #[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    /// The mirrored scalars back the accessors with the same values the depth-0 note would yield,
624    /// so `order_id()` and `creator_account_id()` stay correct without re-fetching the note.
625    #[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    /// `Serializable`/`Deserializable` round-trip preserves every field. Exercised at an advanced
649    /// depth with reduced amounts to catch an offered/requested mix-up.
650    #[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    // PER-ROUND CLASSIFICATION TESTS
675    // --------------------------------------------------------------------------------------------
676
677    /// Minimum-valid inclusion proof — correlator never inspects the path.
678    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    /// Empty header map — these tests don't assert on inserted-note state.
686    fn no_block_headers() -> BTreeMap<BlockNumber, BlockHeader> {
687        BTreeMap::new()
688    }
689
690    /// Active lineage at depth 0 built from a fresh test PSWAP.
691    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        // Override the seeded remaining_* so callers can exercise reduced balances.
695        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    /// `ObservedPswapNote` mirroring `note` (id + tag) so the correlator's tag-based
703    /// payback/remainder split works.
704    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    /// A forged candidate: claims `tag` + `attachment` for our order but carries a `note_id` that
721    /// won't match any reconstruction (here, the depth-0 note's id).
722    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    /// Unwraps a successful, present round update.
740    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    /// 2-candidate partial fill → `Active`, both `remaining_*` reduced.
749    #[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        // Each side carries its note paired with its inclusion proof.
800        assert!(update.payback.is_some());
801        assert!(update.remainder.is_some());
802    }
803
804    /// Note order within a round must not change classification: passing `[remainder, payback]`
805    /// (the reverse of the natural ordering) yields the same result as `[payback, remainder]`.
806    /// Covers the tag-split else-branch.
807    #[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        // Reverse the input order — remainder first.
845        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    /// A candidate that can't be reconstructed (here, a `depth == 0` attachment that trips
858    /// `payback_note`'s "depth must be >= 1" guard) is *filtered*, not fatal: with the tip still
859    /// live and no genuine note, the round yields `Ok(None)` and the lineage stays at its tip. This
860    /// non-fatal skip is what stops a single malformed forged note from stalling the chain.
861    #[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        // Carries the payback tag (so it reaches reconstruction) but a depth-0 attachment that
877        // makes `payback_note` fail — `validate_payback` returns `None` and the candidate is
878        // skipped.
879        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        // Tip still live → no genuine note → no round.
885        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    /// 1-candidate full fill → `FullyFilled`, no remainder, both zeros.
894    #[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        // Smaller initial sizes so the single fill exhausts both sides.
907        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; // exhausts requested side
911        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    /// A 0-candidate consumption marks the lineage `Reclaimed` and zeroes both `remaining_*`
932    /// amounts.
933    #[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        // A reclaim must not carry the pre-reclaim `remaining_requested` into the terminal record.
954        assert_eq!(update.remaining_requested, AssetAmount::ZERO);
955        assert!(update.payback.is_none());
956    }
957
958    /// Same-block multi-fill: round 2 must build against round 1's in-memory-advanced lineage, not
959    /// the original.
960    #[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        // ── Round 1: partial fill, 20 requested for 40 offered.
976        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        // Mirrors `discover_pswap_rounds`'s inner loop.
1005        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        // ── Round 2: full fill of the remainder, exhausts requested side.
1013        let fill2 = new_req1; // = 30
1014        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    /// A payback-tagged note whose id doesn't match the reconstruction is a forgery and is
1039    /// filtered. With the tip still live there's no genuine payback → no round; with the tip
1040    /// consumed the forged-only bucket reads as a reclaim.
1041    #[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        // Same payback tag + attachment, but the depth-0 note's id — won't match the
1059        // reconstruction.
1060        let forged = forged_note(
1061            Note::from(pswap.clone()).id(),
1062            att,
1063            genuine_payback.metadata().tag(),
1064            consumer,
1065            7,
1066        );
1067
1068        // Tip still live → forged-only bucket → not our round.
1069        assert!(matches!(
1070            record.build_round_update(1, &[&forged], &no_block_headers(), Some(&pswap), false,),
1071            Ok(None)
1072        ));
1073
1074        // Tip consumed → forged-only bucket → it was a reclaim.
1075        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    /// A genuine payback plus a forged remainder-tagged note: the forgery is filtered, so the round
1086    /// is classified as a full fill (no remainder), never used as the tip.
1087    #[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        // Remainder tag + plausible attachment, but a non-matching id → forged.
1108        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    /// A padded bucket — genuine payback + genuine remainder + an extra forged note — still
1142    /// classifies as a partial fill; the forgery is dropped, never tripping an ambiguity error.
1143    #[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        // Forged extra, payback-tagged, placed first to prove it's skipped before the genuine one.
1174        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}