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
22/// serialized encoding — do not 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-
40    /// incompatible serialized encodings.
41    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// PSWAP LINEAGE RECORD
52// ================================================================================================
53
54/// Persistent record of one PSWAP order's chain state. The order id and creator
55/// are mirrored here so the common read paths — keying, filtering — stay
56/// lookup-free. Only the remaining *amounts* are stored; the asset pair's faucets,
57/// the script/recipient, and the `note_type` all live on the depth-0 note, fetched
58/// on demand from `output_notes` by `original_note_id` (see
59/// `store::get_original_pswap`) when reconstruction, a depth-0 reclaim, or
60/// asset-pair-tag derivation needs them.
61#[derive(Debug, Clone)]
62pub struct PswapLineageRecord {
63    /// Fetch handle for the depth-0 PSWAP note in `output_notes`. Stable for the
64    /// order's lifetime; distinct from `current_tip_note_id`, which advances
65    /// each round.
66    pub original_note_id: NoteId,
67
68    // Immutable order facts, mirrored from the depth-0 note so keying and
69    // filtering need no store lookup.
70    order_id: Felt,
71    creator_account_id: AccountId,
72
73    /// Current tip's note id. Equals `original_note_id` at depth 0; otherwise a
74    /// remainder we didn't originate.
75    pub current_tip_note_id: NoteId,
76    /// 0 for the original tip; +1 per round. Matches `PswapNoteAttachment::depth()`.
77    pub current_depth: u32,
78    /// Remaining offered amount. The order's offered faucet is chain-invariant and
79    /// recovered from the depth-0 note when needed (e.g. for tag derivation).
80    pub remaining_offered: AssetAmount,
81    /// Remaining requested amount (requested faucet recovered the same way).
82    pub remaining_requested: AssetAmount,
83    pub state: PswapLineageState,
84}
85
86impl PswapLineageRecord {
87    /// Builds the depth-0 record for a PSWAP the wallet has just emitted. Mirrors
88    /// the order id and creator off the note and seeds the mutable tip state: the
89    /// tip is the original note, depth is 0, and `remaining_*` equal the initial
90    /// offered/requested amounts.
91    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    /// Stable identifier (== the depth-0 note's `serial[1]`) shared by every
105    /// note in the chain.
106    pub fn order_id(&self) -> Felt {
107        self.order_id
108    }
109
110    /// Account that created the order (recipient of every payback).
111    pub fn creator_account_id(&self) -> AccountId {
112        self.creator_account_id
113    }
114}
115
116// PSWAP LINEAGE ROUND UPDATE
117// ================================================================================================
118
119/// One round's transition. Fill = payback + remainder (≤1 each); reclaim
120/// = no outputs. Applied atomically by `apply_round`.
121#[derive(Debug, Clone)]
122pub(crate) struct PswapLineageRoundUpdate {
123    pub order_id: Felt,
124    pub round_depth: u32,
125    // Post-round state — all fields below describe the lineage AFTER this round.
126    pub remaining_offered: AssetAmount,
127    pub remaining_requested: AssetAmount,
128    pub state: PswapLineageState,
129    /// New tip; `None` for terminal rounds.
130    pub tip_note_id: Option<NoteId>,
131    /// Commit block's note root, used by `apply_round` to insert payback/remainder as
132    /// `Committed`. `None` on reclaim rounds (no note to insert) and in store-tier fixtures.
133    pub at_block_note_root: Option<Word>,
134    /// Reconstructed payback and its inclusion proof. `None` only on
135    /// reclaim. The note and proof are always observed together in the
136    /// same sync window, so they live or die as a pair.
137    pub payback: Option<(Note, NoteInclusionProof)>,
138    /// Reconstructed remainder and its inclusion proof. `None` on terminal
139    /// rounds (full fill / reclaim). Paired for the same reason as `payback`.
140    pub remainder: Option<(Note, NoteInclusionProof)>,
141}
142
143// OBSERVED CHAIN NOTE
144// ================================================================================================
145
146/// Observed PSWAP-attachment note. The typed attachment carries
147/// `order_id`, `depth`, and amount (fill on payback, payout on remainder)
148/// — role distinguished by [`Self::tag`].
149#[derive(Debug, Clone)]
150pub(crate) struct ObservedPswapNote {
151    pub note_id: NoteId,
152    pub attachment: PswapNoteAttachment,
153    pub sender: AccountId,
154    /// Payback uses the P2ID-style tag; remainder uses the asset-pair tag.
155    pub tag: NoteTag,
156    pub block_num: BlockNumber,
157    pub inclusion_proof: NoteInclusionProof,
158}
159
160// PER-ROUND CLASSIFICATION AND ADVANCE
161// ================================================================================================
162
163impl PswapLineageRecord {
164    /// Builds this round's [`PswapLineageRoundUpdate`] from the chain notes observed at
165    /// `round_depth`.
166    ///
167    /// The `(order_id, depth)` bucket is keyed off attachment fields the sender controls, so the
168    /// raw note set is untrusted. We **validate then classify**: each candidate is
169    /// reconstructed from our stored depth-0 note and kept only if its id matches the observed
170    /// note (a forger can't match without actually emitting a genuine payback/remainder of our
171    /// order). Classification runs on the surviving genuine notes, never on the raw count.
172    ///
173    /// Returns `Ok(None)` when the bucket holds no genuine note and the tip wasn't consumed — the
174    /// notes are forged/unrelated and this isn't our round, so the caller stops advancing.
175    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        // No outputs at all: the only way the round fired is a consumed tip → reclaim.
184        if notes.is_empty() {
185            return Ok(Some(self.build_reclaim_round(round_depth)));
186        }
187
188        // Fetched by the caller before any fill round; absence is a broken invariant.
189        let pswap = original_pswap
190            .ok_or(PswapLineageError::OriginalNoteUnavailable(self.original_note_id))?;
191        let payback_tag = pswap.storage().payback_note_tag();
192
193        // The genuine payback anchors the round; forged candidates reconstruct to a different id
194        // and fall away. More than one genuine payback at a depth is impossible (one tip →
195        // one fill), so the first match is the one.
196        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            // No valid fill: a consumed tip with no genuine payback is a reclaim; otherwise the
203            // notes are forged against a still-live tip and this isn't our round.
204            return Ok(tip_consumed.then(|| self.build_reclaim_round(round_depth)));
205        };
206
207        // Requested amount filled this round, read straight off the validated payback's attachment.
208        let fill_amount = observed_payback.attachment.amount();
209
210        // A genuine remainder (if any) is validated against the post-round balances derived from
211        // the payback's fill. Present → partial fill; absent → full fill.
212        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    /// The genuine remainder for `observed`, if it reconstructs (against the post-round balances
237    /// derived from `fill_amount` and the candidate's own payout) to a matching id; `None`
238    /// otherwise.
239    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    /// Saturating post-round balances after filling `fill_amount` (requested) for `payout_amount`
260    /// (offered). Clamps to zero on over-fill.
261    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    /// Reclaim — cancel branch emits no outputs; only the creator can hit it.
273    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    /// Full fill — only payback emitted; both `remaining_*` → 0. Takes the already-validated
288    /// `payback_note` and its observed note (for the inclusion proof and commit block).
289    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    /// Partial fill — payback + remainder. Takes the already-validated notes; balances come from
312    /// the payback's `fill_amount` and the remainder's payout.
313    #[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    /// Returns the post-round version of the record. Drives the same-block multi-fill loop in
344    /// `discovery`, and is reused by `store::apply_round` to compute the persisted advance.
345    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
357/// The genuine payback for `observed`, if it reconstructs to a matching id; `None` otherwise
358/// (forged, unrelated, or unreconstructable — all skipped, never trusted).
359fn 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
364/// `total - used`, clamped to zero — an over-fill can't drive a balance negative.
365fn 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// PSWAP LINEAGE FILTER
371// ================================================================================================
372
373/// Client-side filter for `crate::pswap::store::list_lineages`. Applied in
374/// Rust after a prefix-scan of the `settings` KV — not a store-trait concept.
375#[derive(Debug, Clone)]
376pub(crate) enum PswapLineageFilter {
377    All,
378    Active,
379    ByCreator(AccountId),
380}
381
382// SERDE HELPERS
383// ================================================================================================
384
385/// Builds a [`PswapLineageRecord`] from its decoded fields. Lives here (not
386/// in a store backend) so alternative backends can reuse it. The only validation
387/// is decoding the `state_byte` into a known [`PswapLineageState`].
388#[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
411// VALUE CODEC
412// ================================================================================================
413
414/// Encodes the record's fields in declaration order: the `original_note_id` fetch handle, the
415/// mirrored order id and creator, then the mutable tip state. Only the remaining *amounts* are
416/// written — the faucets and full note live on the depth-0 note, recovered via `original_note_id`
417/// when needed.
418impl 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    //! Synthetic-PSWAP factory shared across lineage / discovery / store tests.
458
459    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    /// Returns `(sender, creator, offered_faucet, requested_faucet)` —
472    /// four distinct testing `AccountId`s chosen to satisfy PSWAP's
473    /// faucet-distinctness invariant.
474    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    /// Builds a fully-formed [`PswapNote`] for use in tests. Defaults:
484    /// public note type, 100-unit offered, 50-unit requested, serial
485    /// number `[1, 2, 3, 4]`. Override via the params.
486    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    /// Builds a record from a test `PswapNote`, mirroring the immutable scalars
528    /// the observer would extract. Keeps the codec/accessor tests focused on the
529    /// fields they exercise instead of the new wide constructor signature.
530    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    /// Stable byte encoding of `PswapLineageState`. The values are
552    /// persisted in the serialized lineage record; reordering
553    /// would silently corrupt existing stores.
554    #[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    /// Round-trip every state via `try_from_u8`. Belt-and-suspenders
562    /// against a future renumbering breaking the serialized format.
563    #[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    /// Unknown discriminants must error — defends against a future
575    /// store reading a forward-incompatible byte.
576    #[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    /// Happy path for `build_record_from_fields` at depth 0.
585    #[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    /// Happy path at `current_depth > 0`.
608    #[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    /// Unknown state discriminant in a stored record bubbles up as `UnknownState`.
622    #[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    /// The mirrored scalars back the accessors with the same values the depth-0 note would yield,
634    /// so `order_id()` and `creator_account_id()` stay correct without re-fetching the note.
635    #[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    /// `Serializable`/`Deserializable` round-trip preserves every field. Exercised at an advanced
659    /// depth with reduced amounts to catch an offered/requested mix-up.
660    #[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    // PER-ROUND CLASSIFICATION TESTS
685    // --------------------------------------------------------------------------------------------
686
687    /// Minimum-valid inclusion proof — correlator never inspects the path.
688    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    /// Empty header map — these tests don't assert on inserted-note state.
696    fn no_block_headers() -> BTreeMap<BlockNumber, BlockHeader> {
697        BTreeMap::new()
698    }
699
700    /// Active lineage at depth 0 built from a fresh test PSWAP.
701    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        // Override the seeded remaining_* so callers can exercise reduced balances.
705        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    /// `ObservedPswapNote` mirroring `note` (id + tag) so the
713    /// correlator's tag-based payback/remainder split works.
714    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    /// A forged candidate: claims `tag` + `attachment` for our order but carries a `note_id` that
731    /// won't match any reconstruction (here, the depth-0 note's id).
732    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    /// Unwraps a successful, present round update.
750    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    /// 2-candidate partial fill → `Active`, both `remaining_*` reduced.
759    #[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        // Each side carries its note paired with its inclusion proof.
810        assert!(update.payback.is_some());
811        assert!(update.remainder.is_some());
812    }
813
814    /// Note order within a round must not change classification: passing
815    /// `[remainder, payback]` (the reverse of the natural ordering) yields the
816    /// same result as `[payback, remainder]`. Covers the tag-split else-branch.
817    #[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        // Reverse the input order — remainder first.
855        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    /// A candidate that can't be reconstructed (here, a `depth == 0` attachment that trips
868    /// `payback_note`'s "depth must be >= 1" guard) is *filtered*, not fatal: with the tip still
869    /// live and no genuine note, the round yields `Ok(None)` and the lineage stays at its tip. This
870    /// non-fatal skip is what stops a single malformed forged note from stalling the chain.
871    #[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        // Carries the payback tag (so it reaches reconstruction) but a depth-0 attachment that
887        // makes `payback_note` fail — `validate_payback` returns `None` and the candidate
888        // is skipped.
889        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        // Tip still live → no genuine note → no round.
895        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    /// 1-candidate full fill → `FullyFilled`, no remainder, both zeros.
904    #[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        // Smaller initial sizes so the single fill exhausts both sides.
917        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; // exhausts requested side
921        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    /// 0-candidate consumption → `Reclaimed`, both `remaining_*` zeroed.
942    /// Regression guard.
943    #[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        // Regression: reclaim used to leak the pre-reclaim
964        // `remaining_requested` into the terminal record.
965        assert_eq!(update.remaining_requested, AssetAmount::ZERO);
966        assert!(update.payback.is_none());
967    }
968
969    /// Same-block multi-fill: round 2 must build against round 1's
970    /// in-memory-advanced lineage, not the original.
971    #[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        // ── Round 1: partial fill, 20 requested for 40 offered.
987        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        // Mirrors `discover_pswap_rounds`'s inner loop.
1016        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        // ── Round 2: full fill of the remainder, exhausts requested side.
1024        let fill2 = new_req1; // = 30
1025        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    /// A payback-tagged note whose id doesn't match the reconstruction is a forgery and is
1050    /// filtered. With the tip still live there's no genuine payback → no round; with the tip
1051    /// consumed the forged-only bucket reads as a reclaim.
1052    #[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        // Same payback tag + attachment, but the depth-0 note's id — won't match the
1070        // reconstruction.
1071        let forged = forged_note(
1072            Note::from(pswap.clone()).id(),
1073            att,
1074            genuine_payback.metadata().tag(),
1075            consumer,
1076            7,
1077        );
1078
1079        // Tip still live → forged-only bucket → not our round.
1080        assert!(matches!(
1081            record.build_round_update(1, &[&forged], &no_block_headers(), Some(&pswap), false,),
1082            Ok(None)
1083        ));
1084
1085        // Tip consumed → forged-only bucket → it was a reclaim.
1086        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    /// A genuine payback plus a forged remainder-tagged note: the forgery is filtered, so the round
1097    /// is classified as a full fill (no remainder), never used as the tip.
1098    #[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        // Remainder tag + plausible attachment, but a non-matching id → forged.
1119        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    /// A padded bucket — genuine payback + genuine remainder + an extra forged note — still
1153    /// classifies as a partial fill; the forgery is dropped, never tripping an ambiguity error.
1154    #[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        // Forged extra, payback-tagged, placed first to prove it's skipped before the genuine one.
1185        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}