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