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}