Skip to main content

miden_multisig_client/client/
notes.rs

1//! Note filtering and listing operations for MultisigClient.
2//!
3//! This module handles listing consumable notes and filtering them
4//! by various criteria (faucet, amount, etc.).
5
6use miden_client::note::NoteConsumptionStatus;
7use miden_protocol::account::AccountId;
8use miden_protocol::asset::Asset;
9use miden_protocol::note::NoteId;
10
11use super::MultisigClient;
12use crate::error::{MultisigError, Result};
13
14/// A wrapper type for a consumable note with simplified information.
15#[derive(Debug, Clone)]
16pub struct ConsumableNote {
17    /// The note ID.
18    pub id: NoteId,
19    /// Assets contained in the note.
20    pub assets: Vec<Asset>,
21}
22
23impl ConsumableNote {
24    /// Returns the total amount of a specific fungible asset in this note.
25    pub fn amount_for_faucet(&self, faucet_id: AccountId) -> u64 {
26        self.assets
27            .iter()
28            .filter_map(|asset| match asset {
29                Asset::Fungible(fungible) if fungible.faucet_id() == faucet_id => {
30                    Some(fungible.amount().as_u64())
31                }
32                _ => None,
33            })
34            .sum()
35    }
36
37    /// Returns true if this note contains fungible assets from the specified faucet.
38    pub fn has_faucet(&self, faucet_id: AccountId) -> bool {
39        self.assets.iter().any(|asset| match asset {
40            Asset::Fungible(fungible) => fungible.faucet_id() == faucet_id,
41            Asset::NonFungible(_) => false,
42        })
43    }
44}
45
46/// Filter criteria for listing consumable notes.
47///
48/// # Validation
49///
50/// - `min_amount` requires `faucet_id` to be set (amount is per-faucet)
51/// - Use `validate()` to check filter validity before use
52#[derive(Debug, Clone, Default)]
53pub struct NoteFilter {
54    /// Only include notes containing assets from this faucet.
55    pub faucet_id: Option<AccountId>,
56    /// Only include notes with at least this amount (for the specified faucet).
57    /// Requires `faucet_id` to be set.
58    pub min_amount: Option<u64>,
59}
60
61impl NoteFilter {
62    /// Creates a new filter for notes from a specific faucet.
63    pub fn by_faucet(faucet_id: AccountId) -> Self {
64        Self {
65            faucet_id: Some(faucet_id),
66            min_amount: None,
67        }
68    }
69
70    /// Creates a new filter for notes from a specific faucet with minimum amount.
71    pub fn by_faucet_min_amount(faucet_id: AccountId, min_amount: u64) -> Self {
72        Self {
73            faucet_id: Some(faucet_id),
74            min_amount: Some(min_amount),
75        }
76    }
77
78    /// Validates the filter configuration.
79    ///
80    /// Returns an error if `min_amount` is set without `faucet_id`,
81    /// since amount filtering requires a specific faucet to check against.
82    pub fn validate(&self) -> Result<()> {
83        if self.min_amount.is_some() && self.faucet_id.is_none() {
84            return Err(MultisigError::InvalidFilter(
85                "min_amount requires faucet_id to be set".to_string(),
86            ));
87        }
88        Ok(())
89    }
90}
91
92impl MultisigClient {
93    /// Lists notes that can be consumed by the current account.
94    ///
95    /// Returns a list of notes that are committed on-chain and can be consumed
96    /// immediately by the multisig account.
97    pub async fn list_consumable_notes(&mut self) -> Result<Vec<ConsumableNote>> {
98        let account_id = self.require_account()?.id();
99
100        let consumable = self
101            .miden_client
102            .get_consumable_notes(Some(account_id))
103            .await
104            .map_err(|e| {
105                MultisigError::MidenClient(format!("failed to get consumable notes: {}", e))
106            })?;
107
108        // Convert to our wrapper type, filtering for notes consumable
109        let notes = consumable
110            .into_iter()
111            .filter_map(|(record, relevances)| {
112                // Only include notes consumable now by our account
113                let can_consume_now = relevances.iter().any(|(id, status)| {
114                    *id == account_id
115                        && matches!(
116                            status,
117                            NoteConsumptionStatus::Consumable
118                                | NoteConsumptionStatus::ConsumableWithAuthorization
119                        )
120                });
121                if can_consume_now {
122                    record.id().map(|id| ConsumableNote {
123                        id,
124                        assets: record.assets().iter().cloned().collect(),
125                    })
126                } else {
127                    None
128                }
129            })
130            .collect();
131
132        Ok(notes)
133    }
134
135    /// Returns a list of all notes with their consumption status
136    pub async fn list_notes_with_status(
137        &mut self,
138    ) -> Result<Vec<(ConsumableNote, Vec<(AccountId, String)>)>> {
139        let account_id = self.require_account()?.id();
140
141        let notes = self
142            .miden_client
143            .get_consumable_notes(Some(account_id))
144            .await
145            .map_err(|e| MultisigError::MidenClient(format!("failed to get notes: {}", e)))?;
146
147        let result = notes
148            .into_iter()
149            .filter(|(_, relevances)| relevances.iter().any(|(id, _)| *id == account_id))
150            .filter_map(|(record, relevances)| {
151                let note = ConsumableNote {
152                    id: record.id()?,
153                    assets: record.assets().iter().cloned().collect(),
154                };
155                let statuses: Vec<(AccountId, String)> = relevances
156                    .into_iter()
157                    .map(|(id, status)| (id, format!("{:?}", status)))
158                    .collect();
159                Some((note, statuses))
160            })
161            .collect();
162
163        Ok(result)
164    }
165
166    /// Returns a list of all committed notes (not just consumable).
167    pub async fn list_committed_notes(&mut self) -> Result<Vec<ConsumableNote>> {
168        let account_id = self.require_account()?.id();
169
170        let notes = self
171            .miden_client
172            .get_consumable_notes(Some(account_id))
173            .await
174            .map_err(|e| MultisigError::MidenClient(format!("failed to get notes: {}", e)))?;
175
176        let result = notes
177            .into_iter()
178            .filter(|(_, relevances)| relevances.iter().any(|(id, _)| *id == account_id))
179            .filter_map(|(record, _)| {
180                Some(ConsumableNote {
181                    id: record.id()?,
182                    assets: record.assets().iter().cloned().collect(),
183                })
184            })
185            .collect();
186
187        Ok(result)
188    }
189
190    /// Lists consumable notes filtered by the given criteria.
191    ///
192    /// This is a convenience method that combines `list_consumable_notes` with
193    /// filtering. Use this to find notes from a specific faucet or above a
194    /// minimum amount.
195    ///
196    /// # Example
197    ///
198    /// ```ignore
199    /// use miden_multisig_client::NoteFilter;
200    ///
201    /// // Find notes from a specific faucet with at least 1000 tokens
202    /// let filter = NoteFilter {
203    ///     faucet_id: Some(my_faucet_id),
204    ///     min_amount: Some(1000),
205    /// };
206    /// let notes = client.list_consumable_notes_filtered(filter).await?;
207    /// ```
208    pub async fn list_consumable_notes_filtered(
209        &mut self,
210        filter: NoteFilter,
211    ) -> Result<Vec<ConsumableNote>> {
212        // Validate filter configuration
213        filter.validate()?;
214
215        let notes = self.list_consumable_notes().await?;
216
217        let filtered = notes
218            .into_iter()
219            .filter(|note| {
220                // Filter by faucet
221                if let Some(faucet_id) = filter.faucet_id {
222                    if !note.has_faucet(faucet_id) {
223                        return false;
224                    }
225                    // Filter by minimum amount (faucet_id is guaranteed to be set if min_amount is)
226                    if let Some(min) = filter.min_amount
227                        && note.amount_for_faucet(faucet_id) < min
228                    {
229                        return false;
230                    }
231                }
232                true
233            })
234            .collect();
235
236        Ok(filtered)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    // Use a regular account ID for filter validation tests (no FungibleAsset creation)
245    fn test_account_id() -> AccountId {
246        AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap()
247    }
248
249    #[test]
250    fn test_note_filter_validate_min_amount_without_faucet() {
251        let filter = NoteFilter {
252            faucet_id: None,
253            min_amount: Some(1000),
254        };
255        assert!(filter.validate().is_err());
256    }
257
258    #[test]
259    fn test_note_filter_validate_valid() {
260        // No filter
261        let filter = NoteFilter::default();
262        assert!(filter.validate().is_ok());
263
264        // Faucet only (any account ID works for validation)
265        let filter = NoteFilter::by_faucet(test_account_id());
266        assert!(filter.validate().is_ok());
267
268        // Faucet + min_amount
269        let filter = NoteFilter::by_faucet_min_amount(test_account_id(), 1000);
270        assert!(filter.validate().is_ok());
271    }
272
273    #[test]
274    fn test_consumable_note_empty_assets() {
275        // Test with empty assets - amount should be 0, has_faucet should be false
276        use miden_protocol::Word;
277        use miden_protocol::note::NoteId;
278
279        let note = ConsumableNote {
280            id: NoteId::from_raw(Word::default()),
281            assets: vec![],
282        };
283
284        assert_eq!(note.amount_for_faucet(test_account_id()), 0);
285        assert!(!note.has_faucet(test_account_id()));
286    }
287}