Skip to main content

miden_client/pswap/
mod.rs

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