miden_multisig_client/client/
notes.rs1use 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#[derive(Debug, Clone)]
19pub struct ConsumableNote {
20 pub id: NoteId,
22 pub assets: Vec<Asset>,
24}
25
26impl ConsumableNote {
27 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 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#[derive(Debug, Clone, Default)]
56pub struct NoteFilter {
57 pub faucet_id: Option<AccountId>,
59 pub min_amount: Option<u64>,
62}
63
64impl NoteFilter {
65 pub fn by_faucet(faucet_id: AccountId) -> Self {
67 Self {
68 faucet_id: Some(faucet_id),
69 min_amount: None,
70 }
71 }
72
73 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 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 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 let notes = consumable
113 .into_iter()
114 .filter_map(|(record, relevances)| {
115 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 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 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 pub async fn list_consumable_notes_filtered(
212 &mut self,
213 filter: NoteFilter,
214 ) -> Result<Vec<ConsumableNote>> {
215 filter.validate()?;
217
218 let notes = self.list_consumable_notes().await?;
219
220 let filtered = notes
221 .into_iter()
222 .filter(|note| {
223 if let Some(faucet_id) = filter.faucet_id {
225 if !note.has_faucet(faucet_id) {
226 return false;
227 }
228 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 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 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 let filter = NoteFilter::default();
312 assert!(filter.validate().is_ok());
313
314 let filter = NoteFilter::by_faucet(test_account_id());
316 assert!(filter.validate().is_ok());
317
318 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 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}