Skip to main content

miden_client/pswap/
mod.rs

1//! PSWAP chain tracking — follows partial-swap orders across fills so the creator can always see
2//! the current tip and reclaim the unfilled balance.
3//!
4//! Flow:
5//! 1. Create → persist a [`PswapLineageRecord`] + asset-pair tag subscription.
6//! 2. Sync → [`PswapChainObserver`] collects PSWAP-attachment notes;
7//!    `discovery::discover_pswap_rounds` correlates them with tracked-note consumption events and
8//!    emits one `PswapLineageRoundUpdate` per round.
9//! 3. Reclaim → [`Client::build_pswap_cancel_by_order`].
10//!
11//! Protocol invariants (≤1 payback + ≤1 remainder per round, attachment word layout, deterministic
12//! reconstruction) live on `miden_standards::note::PswapNote`.
13//!
14//! # Trust model
15//!
16//! Observed notes are matched to an order by the `order_id` on their attachment, which the sender
17//! controls — so the `(order_id, depth)` bucket is untrusted. Anyone who knows one of our live
18//! order ids (public orders expose it on-chain) can publish a note carrying that id and our tag. We
19//! never trust such a note on its face: for each candidate we reconstruct the note it *should* be
20//! from our stored depth-0 note (`payback_note` / `remainder_note`) and accept it only if the
21//! reconstructed id matches the observed id. A forger can't produce a matching id without actually
22//! emitting a genuine payback/remainder of our order (which pays our creator), so this is an
23//! authenticity check, not a checksum. Candidates that fail are skipped — they can't advance the
24//! tip, change a round's classification, or be inserted as consumable notes. Classification runs
25//! only on the surviving genuine notes, never on the raw observed count.
26
27pub(crate) mod discovery;
28pub(crate) mod errors;
29pub(crate) mod lineage;
30pub(crate) mod observer;
31pub(crate) mod store;
32
33// `PswapTransactionObserver` is defined inline below in this file.
34use alloc::boxed::Box;
35use alloc::collections::BTreeSet;
36use alloc::sync::Arc;
37use alloc::vec::Vec;
38
39use async_trait::async_trait;
40pub use errors::PswapLineageError;
41use lineage::PswapLineageFilter;
42pub use lineage::{PswapLineageRecord, PswapLineageState};
43use miden_protocol::Felt;
44use miden_protocol::account::AccountId;
45use miden_protocol::note::Note;
46use miden_standards::note::PswapNote;
47use miden_tx::auth::TransactionAuthenticator;
48pub use observer::PswapChainObserver;
49
50use crate::store::{NoteFilter, Store};
51use crate::sync::{NoteTagRecord, NoteTagSource};
52use crate::transaction::{
53    TransactionObserver,
54    TransactionRequest,
55    TransactionRequestBuilder,
56    TransactionResult,
57    notes_from_output,
58};
59use crate::{Client, ClientError};
60
61// PSWAP TRANSACTION OBSERVER
62// ================================================================================================
63
64/// Registers a [`PswapLineageRecord`] + asset-pair tag subscription for every depth-0 PSWAP this
65/// wallet emits. Creator-agnostic (service wallets are tracked too; reclaim surfaces
66/// `CreatorNotLocal` later).
67pub struct PswapTransactionObserver {
68    store: Arc<dyn Store>,
69}
70
71impl PswapTransactionObserver {
72    pub fn new(store: Arc<dyn Store>) -> Self {
73        Self { store }
74    }
75}
76
77#[async_trait(?Send)]
78impl TransactionObserver for PswapTransactionObserver {
79    fn name(&self) -> &'static str {
80        "PswapTransactionObserver"
81    }
82
83    async fn apply(&self, tx_result: &TransactionResult) -> Result<(), ClientError> {
84        let output_notes = tx_result.executed_transaction().output_notes();
85
86        for note in notes_from_output(output_notes) {
87            let Ok(pswap) = PswapNote::try_from(note) else {
88                continue;
89            };
90
91            // Remainders we emitted filling someone else's order — skip.
92            if pswap.parent_depth() != 0 {
93                continue;
94            }
95
96            // The full note lives in `output_notes`; the record keeps only its id plus the
97            // immutable order facts (see `PswapLineageRecord`).
98            let record = PswapLineageRecord::new_depth_zero(note.id(), &pswap);
99
100            store::put_lineage(&self.store, &record).await?;
101            self.store
102                .add_note_tag(NoteTagRecord {
103                    // The asset-pair tag is derived straight from the note we just parsed; the
104                    // record stores only amounts, not the faucets the tag needs.
105                    tag: PswapNote::create_tag(
106                        pswap.note_type(),
107                        pswap.offered_asset(),
108                        pswap.storage().min_requested_asset(),
109                    ),
110                    source: NoteTagSource::Subscription(record.original_note_id.as_word()),
111                })
112                .await?;
113        }
114
115        Ok(())
116    }
117}
118
119// =============================================================================
120// PUBLIC API
121// =============================================================================
122
123impl<AUTH: TransactionAuthenticator + Sync + 'static> Client<AUTH> {
124    /// Returns every PSWAP lineage tracked by this client.
125    pub async fn pswap_lineages(&self) -> Result<Vec<PswapLineageRecord>, ClientError> {
126        store::list_lineages(&self.store, PswapLineageFilter::All)
127            .await
128            .map_err(Into::into)
129    }
130
131    /// Returns lineages created by a specific local account.
132    pub async fn pswap_lineages_for(
133        &self,
134        creator: AccountId,
135    ) -> Result<Vec<PswapLineageRecord>, ClientError> {
136        store::list_lineages(&self.store, PswapLineageFilter::ByCreator(creator))
137            .await
138            .map_err(Into::into)
139    }
140
141    /// Returns the still-open PSWAP lineages — orders that are neither fully filled nor reclaimed
142    /// (i.e. the creator's live, reclaimable orders).
143    pub async fn pswap_active_lineages(&self) -> Result<Vec<PswapLineageRecord>, ClientError> {
144        store::list_lineages(&self.store, PswapLineageFilter::Active)
145            .await
146            .map_err(Into::into)
147    }
148
149    /// Returns the lineage for one order, or `None` if not tracked.
150    pub async fn pswap_lineage(
151        &self,
152        order_id: Felt,
153    ) -> Result<Option<PswapLineageRecord>, ClientError> {
154        store::get_lineage(&self.store, order_id).await.map_err(Into::into)
155    }
156
157    /// Builds a tx reclaiming the unfilled offered asset on the current tip of an Active lineage.
158    /// See [`PswapLineageError`] for failure modes.
159    pub async fn build_pswap_cancel_by_order(
160        &self,
161        order_id: Felt,
162    ) -> Result<TransactionRequest, ClientError> {
163        let lineage = store::get_lineage(&self.store, order_id)
164            .await?
165            .ok_or(PswapLineageError::NotFound(order_id))?;
166
167        if lineage.state != PswapLineageState::Active {
168            return Err(PswapLineageError::NotActive(lineage.state).into());
169        }
170
171        // Fail loud now — opaque signing failure later is worse.
172        let creator = lineage.creator_account_id();
173        let local_accounts: BTreeSet<_> = self.store.get_account_ids().await?.into_iter().collect();
174        if !local_accounts.contains(&creator) {
175            return Err(PswapLineageError::CreatorNotLocal(creator).into());
176        }
177
178        // At depth 0 the tip is the original PSWAP, fetched from `output_notes` by its id. At depth
179        // > 0 the tip is a remainder discovered during sync and persisted to `input_notes`.
180        let tip_note: Note = if lineage.current_depth == 0 {
181            Note::from(store::get_original_pswap(&self.store, lineage.original_note_id).await?)
182        } else {
183            let record = self
184                .store
185                .get_input_notes(NoteFilter::Unique(lineage.current_tip_note_id))
186                .await?
187                .into_iter()
188                .next()
189                .ok_or(PswapLineageError::TipMissing)?;
190            record.try_into().map_err(ClientError::NoteRecordConversionError)?
191        };
192
193        TransactionRequestBuilder::new()
194            .build_pswap_cancel(tip_note, lineage.creator_account_id())
195            .map_err(ClientError::TransactionRequestError)
196    }
197}