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}