Skip to main content

miden_client/pswap/
observer.rs

1//! Per-note observer that collects every PSWAP-attachment note seen during sync. Lineage-scope
2//! filtering happens later, in `discovery`.
3
4use alloc::boxed::Box;
5use alloc::sync::Arc;
6use alloc::vec::Vec;
7
8use async_trait::async_trait;
9use miden_protocol::asset::AssetAmount;
10use miden_protocol::note::NoteAttachments;
11use miden_standards::note::{PswapNote, PswapNoteAttachment};
12use tracing::warn;
13
14use crate::ClientError;
15use crate::pswap::discovery::discover_pswap_rounds;
16use crate::pswap::lineage::ObservedPswapNote;
17use crate::rpc::domain::note::SyncedNote;
18use crate::store::Store;
19use crate::sync::NoteObserver;
20use crate::utils::RwLock;
21
22// PSWAP CHAIN OBSERVER
23// ================================================================================================
24
25/// Per-sync collector of PSWAP-attachment notes seen this sync.
26///
27/// - `observe()` runs per-note during sync: reads the PSWAP attachment word straight off the note's
28///   resolved attachments (carried inline on the sync window) and records a `ObservedPswapNote`. No
29///   RPC round trip, no DB write.
30/// - `apply()` runs once post-sync: drains the collector, runs the correlator, applies round
31///   updates.
32pub struct PswapChainObserver {
33    store: Arc<dyn Store>,
34    /// `observe()` writes, `apply()` drains; never concurrent. The observer is shared via the outer
35    /// `Arc<dyn NoteObserver>` and only ever touched through `&self`, so the `RwLock` alone
36    /// provides the needed interior mutability — no inner `Arc`.
37    chain_note_updates: RwLock<Vec<ObservedPswapNote>>,
38}
39
40impl PswapChainObserver {
41    pub fn new(store: Arc<dyn Store>) -> Self {
42        Self {
43            store,
44            chain_note_updates: RwLock::new(Vec::new()),
45        }
46    }
47}
48
49#[async_trait(?Send)]
50impl NoteObserver for PswapChainObserver {
51    fn name(&self) -> &'static str {
52        "PswapChainObserver"
53    }
54
55    async fn observe(&self, note: &SyncedNote) -> Result<bool, ClientError> {
56        // Notes without a PSWAP attachment are the common case; `extract_pswap_attachment`
57        // fast-rejects them. Foreign-order filtering happens later in `discovery`.
58        let Some(attachment) = extract_pswap_attachment(&note.attachments) else {
59            return Ok(false);
60        };
61
62        let inclusion_proof = note.inclusion_proof.clone();
63        self.chain_note_updates.write().push(ObservedPswapNote {
64            note_id: note.note_id,
65            attachment,
66            sender: note.metadata.sender(),
67            tag: note.metadata.tag(),
68            block_num: inclusion_proof.location().block_num(),
69            inclusion_proof,
70        });
71        Ok(true)
72    }
73
74    /// Drains the collector, runs the correlator, applies round updates. Per-round failures are
75    /// logged, not propagated.
76    async fn apply(&self, sync_update: &crate::sync::StateSyncUpdate) -> Result<(), ClientError> {
77        let chain_note_updates = core::mem::take(&mut *self.chain_note_updates.write());
78
79        // Nothing observed AND nothing consumed — correlator has no work.
80        if chain_note_updates.is_empty()
81            && sync_update.note_updates().consumed_note_ids().next().is_none()
82        {
83            return Ok(());
84        }
85
86        let round_updates =
87            discover_pswap_rounds(self.store.clone(), sync_update, &chain_note_updates).await?;
88
89        for round_update in round_updates {
90            if let Err(err) = crate::pswap::store::apply_round(&self.store, &round_update).await {
91                warn!(
92                    order_id = round_update.order_id.as_canonical_u64(),
93                    round_depth = round_update.round_depth,
94                    error = ?err,
95                    "apply_round failed; lineage left at previous tip",
96                );
97            }
98        }
99        Ok(())
100    }
101}
102
103// ---------------------------------------------------------------------------
104// HELPERS
105// ---------------------------------------------------------------------------
106
107/// Pulls the typed [`PswapNoteAttachment`] off a note's attachment word `[amount, order_id, depth,
108/// 0]`. Returns `None` for notes without a PSWAP-scheme attachment or with malformed content.
109fn extract_pswap_attachment(attachments: &NoteAttachments) -> Option<PswapNoteAttachment> {
110    let pswap_attach = attachments.find(PswapNote::PSWAP_ATTACHMENT_SCHEME)?;
111    let word = pswap_attach.content().as_words().first()?;
112
113    let amount = AssetAmount::new(word[0].as_canonical_u64()).ok()?;
114    let order_id = word[1];
115    let depth = u32::try_from(word[2].as_canonical_u64()).ok()?;
116    Some(PswapNoteAttachment::new(amount, order_id, depth))
117}
118
119// ---------------------------------------------------------------------------
120// TESTS
121// ---------------------------------------------------------------------------
122
123#[cfg(test)]
124mod tests {
125    //! Reject-branch coverage for `extract_pswap_attachment` — the per-note fast-path that turns a
126    //! raw attachment word into a typed [`PswapNoteAttachment`] (or rejects it).
127    use alloc::vec::Vec;
128
129    use miden_protocol::note::{NoteAttachment, NoteAttachmentScheme, NoteAttachments};
130    use miden_protocol::{Felt, Word};
131    use miden_standards::note::PswapNote;
132
133    use super::*;
134
135    /// A PSWAP attachment word `[amount, order_id, depth, 0]`.
136    fn pswap_word(amount: u64, order_id: u64, depth: u64) -> Word {
137        Word::from([
138            Felt::new(amount).unwrap(),
139            Felt::new(order_id).unwrap(),
140            Felt::new(depth).unwrap(),
141            Felt::new(0).unwrap(),
142        ])
143    }
144
145    /// Wraps `word` in a single PSWAP-scheme attachment.
146    fn pswap_attachments(word: Word) -> NoteAttachments {
147        NoteAttachments::from(NoteAttachment::with_word(PswapNote::PSWAP_ATTACHMENT_SCHEME, word))
148    }
149
150    /// Well-formed word round-trips into the typed attachment.
151    #[test]
152    fn extract_pswap_attachment_reads_wellformed_word() {
153        let parsed = extract_pswap_attachment(&pswap_attachments(pswap_word(25, 0xabcd, 3)))
154            .expect("valid PSWAP word must parse");
155        assert_eq!(u64::from(parsed.amount()), 25);
156        assert_eq!(parsed.order_id().as_canonical_u64(), 0xabcd);
157        assert_eq!(parsed.depth(), 3);
158    }
159
160    /// No PSWAP-scheme attachment present → `None`. Covers both the empty set and the "has
161    /// attachments, but none is ours" case (the common path for unrelated notes during sync).
162    #[test]
163    fn extract_pswap_attachment_rejects_missing_scheme() {
164        let empty = NoteAttachments::new(Vec::new()).unwrap();
165        assert!(extract_pswap_attachment(&empty).is_none());
166
167        // Scheme 1 ≠ the PSWAP scheme (3).
168        let other = NoteAttachments::from(NoteAttachment::with_word(
169            NoteAttachmentScheme::new(1).unwrap(),
170            pswap_word(1, 2, 3),
171        ));
172        assert!(extract_pswap_attachment(&other).is_none());
173    }
174
175    /// `amount` above `AssetAmount::MAX` is rejected, not panicked on.
176    #[test]
177    fn extract_pswap_attachment_rejects_oversized_amount() {
178        let word = pswap_word(AssetAmount::MAX.as_u64() + 1, 7, 1);
179        assert!(extract_pswap_attachment(&pswap_attachments(word)).is_none());
180    }
181
182    /// `depth` above `u32::MAX` is rejected, not panicked on. The amount field is valid so the
183    /// parser reaches the depth check.
184    #[test]
185    fn extract_pswap_attachment_rejects_oversized_depth() {
186        let word = pswap_word(10, 7, u64::from(u32::MAX) + 1);
187        assert!(extract_pswap_attachment(&pswap_attachments(word)).is_none());
188    }
189}