1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_truncation)]
3#![allow(clippy::missing_errors_doc)]
4#![allow(clippy::missing_panics_doc)]
5#![allow(clippy::module_name_repetitions)]
6#![allow(clippy::must_use_candidate)]
7#![allow(clippy::return_self_not_must_use)]
8
9#[cfg(feature = "uniffi")]
10::uniffi::setup_scaffolding!();
11
12pub mod backup;
14#[cfg(feature = "cli")]
16mod cli;
17pub mod client_db;
19#[cfg(feature = "uniffi")]
21pub mod ffi;
22mod input;
24mod oob;
26pub mod output;
28
29pub mod events;
30
31pub mod api;
33
34pub mod repair_wallet;
35
36pub mod visualize;
37
38use std::cmp::{Ordering, min};
39use std::collections::{BTreeMap, BTreeSet};
40use std::fmt;
41use std::fmt::{Display, Formatter};
42use std::io::Read;
43use std::str::FromStr;
44use std::sync::{Arc, RwLock};
45use std::time::Duration;
46
47use anyhow::{Context as _, anyhow, bail, ensure};
48use api::MintFederationApi;
49use async_stream::{stream, try_stream};
50use backup::recovery::{MintRecovery, RecoveryStateV2};
51use base64::Engine as _;
52use bitcoin_hashes::{Hash, HashEngine as BitcoinHashEngine, sha256, sha256t};
53use client_db::{
54 DbKeyPrefix, NoteKeyPrefix, RecoveryFinalizedKey, RecoveryStateKey, RecoveryStateV2Key,
55 ReusedNoteIndices, migrate_state_to_v2, migrate_to_v1,
56};
57use events::{NoteSpent, OOBNotesReissued, OOBNotesSpent, ReceivePaymentEvent, SendPaymentEvent};
58use fedimint_api_client::api::DynModuleApi;
59use fedimint_client_module::db::{ClientModuleMigrationFn, migrate_state};
60use fedimint_client_module::module::init::{
61 ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs,
62};
63use fedimint_client_module::module::recovery::RecoveryProgress;
64use fedimint_client_module::module::{
65 ClientContext, ClientModule, IClientModule, OutPointRange, PrimaryModulePriority,
66 PrimaryModuleSupport,
67};
68use fedimint_client_module::oplog::{OperationLogEntry, UpdateStreamOrOutcome};
69use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
70use fedimint_client_module::transaction::{
71 ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
72 ClientOutputSM, FeeQuote, FeeQuoteRequest, TransactionBuilder,
73};
74use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
75use fedimint_core::base32::{FEDIMINT_PREFIX, encode_prefixed};
76use fedimint_core::config::{FederationId, FederationIdPrefix};
77use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
78use fedimint_core::db::{
79 AutocommitError, Database, DatabaseTransaction, DatabaseVersion,
80 IDatabaseTransactionOpsCoreTyped,
81};
82use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
83use fedimint_core::invite_code::InviteCode;
84use fedimint_core::module::registry::{ModuleDecoderRegistry, ModuleRegistry};
85use fedimint_core::module::{
86 AmountUnit, Amounts, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
87};
88use fedimint_core::secp256k1::rand::prelude::IteratorRandom;
89use fedimint_core::secp256k1::rand::thread_rng;
90use fedimint_core::secp256k1::{All, Keypair, Secp256k1};
91use fedimint_core::util::{BoxFuture, BoxStream, NextOrPending, SafeUrl};
92use fedimint_core::{
93 Amount, IdxRange, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId, apply,
94 async_trait_maybe_send, base32, push_db_pair_items,
95};
96use fedimint_derive_secret::{ChildId, DerivableSecret};
97use fedimint_logging::LOG_CLIENT_MODULE_MINT;
98pub use fedimint_mint_common as common;
99use fedimint_mint_common::config::{FeeConsensus, MintClientConfig};
100pub use fedimint_mint_common::*;
101use futures::future::try_join_all;
102use futures::{StreamExt, pin_mut};
103use hex::ToHex;
104use input::MintInputStateCreatedBundle;
105use itertools::Itertools as _;
106use output::MintOutputStatesCreatedMulti;
107use serde::{Deserialize, Serialize};
108use strum::IntoEnumIterator;
109use tbs::AggregatePublicKey;
110use thiserror::Error;
111use tracing::{debug, warn};
112
113use crate::backup::EcashBackup;
114use crate::client_db::{
115 CancelledOOBSpendKey, CancelledOOBSpendKeyPrefix, NextECashNoteIndexKey,
116 NextECashNoteIndexKeyPrefix, NoteKey,
117};
118use crate::input::{MintInputCommon, MintInputStateMachine, MintInputStates};
119use crate::oob::{MintOOBStateMachine, MintOOBStates, MintOOBStatesCreatedMulti};
120use crate::output::{
121 MintOutputCommon, MintOutputStateMachine, MintOutputStates, NoteIssuanceRequest,
122};
123
124const MINT_E_CASH_TYPE_CHILD_ID: ChildId = ChildId(0);
125
126const OOB_SPEND_NO_TIMEOUT: Duration = Duration::MAX;
127
128#[derive(Clone)]
129struct PeerSelector {
130 latency: Arc<RwLock<BTreeMap<PeerId, Duration>>>,
131}
132
133impl PeerSelector {
134 fn new(peers: BTreeSet<PeerId>) -> Self {
135 let latency = peers
136 .into_iter()
137 .map(|peer| (peer, Duration::ZERO))
138 .collect();
139
140 Self {
141 latency: Arc::new(RwLock::new(latency)),
142 }
143 }
144
145 fn choose_peer(&self) -> PeerId {
146 let latency = self.latency.read().expect("poisoned");
147
148 let peer_a = latency.iter().choose(&mut thread_rng()).expect("no peers");
149 let peer_b = latency.iter().choose(&mut thread_rng()).expect("no peers");
150
151 if peer_a.1 <= peer_b.1 {
152 *peer_a.0
153 } else {
154 *peer_b.0
155 }
156 }
157
158 fn report(&self, peer: PeerId, duration: Duration) {
159 self.latency
160 .write()
161 .expect("poisoned")
162 .entry(peer)
163 .and_modify(|latency| *latency = *latency * 9 / 10 + duration / 10)
164 .or_insert(duration);
165 }
166
167 fn remove(&self, peer: PeerId) {
168 self.latency.write().expect("poisoned").remove(&peer);
169 }
170}
171
172async fn download_slice_with_hash(
174 module_api: DynModuleApi,
175 peer_selector: PeerSelector,
176 start: u64,
177 end: u64,
178 expected_hash: sha256::Hash,
179) -> Vec<RecoveryItem> {
180 const TIMEOUT: Duration = Duration::from_secs(30);
181
182 loop {
183 let peer = peer_selector.choose_peer();
184 let start_time = fedimint_core::time::now();
185
186 match tokio::time::timeout(TIMEOUT, module_api.fetch_recovery_slice(peer, start, end))
187 .await
188 .map_err(Into::into)
189 .and_then(|r| r)
190 {
191 Ok(data) => {
192 let elapsed = fedimint_core::time::now()
193 .duration_since(start_time)
194 .unwrap_or(Duration::ZERO);
195
196 peer_selector.report(peer, elapsed);
197
198 if data.consensus_hash::<sha256::Hash>() == expected_hash {
199 return data;
200 }
201
202 peer_selector.remove(peer);
203 }
204 Err(..) => {
205 peer_selector.report(peer, TIMEOUT);
206 }
207 }
208 }
209}
210
211#[derive(Clone, Debug, Encodable, PartialEq, Eq)]
219pub struct OOBNotes(Vec<OOBNotesPart>);
220
221#[cfg(feature = "uniffi")]
222uniffi::custom_type!(OOBNotes, String, {
223 lower: |n| n.to_string(),
224 try_lift: |s| OOBNotes::from_str(&s),
225});
226
227#[derive(Clone, Debug, Decodable, Encodable, PartialEq, Eq)]
230enum OOBNotesPart {
231 Notes(TieredMulti<SpendableNote>),
232 FederationIdPrefix(FederationIdPrefix),
233 Invite {
237 peer_apis: Vec<(PeerId, SafeUrl)>,
239 federation_id: FederationId,
240 },
241 ApiSecret(String),
242 #[encodable_default]
243 Default {
244 variant: u64,
245 bytes: Vec<u8>,
246 },
247}
248
249impl OOBNotes {
250 pub fn new(
251 federation_id_prefix: FederationIdPrefix,
252 notes: TieredMulti<SpendableNote>,
253 ) -> Self {
254 Self(vec![
255 OOBNotesPart::FederationIdPrefix(federation_id_prefix),
256 OOBNotesPart::Notes(notes),
257 ])
258 }
259
260 pub fn new_with_invite(notes: TieredMulti<SpendableNote>, invite: &InviteCode) -> Self {
261 let mut data = vec![
262 OOBNotesPart::FederationIdPrefix(invite.federation_id().to_prefix()),
265 OOBNotesPart::Notes(notes),
266 OOBNotesPart::Invite {
267 peer_apis: vec![(invite.peer(), invite.url())],
268 federation_id: invite.federation_id(),
269 },
270 ];
271 if let Some(api_secret) = invite.api_secret() {
272 data.push(OOBNotesPart::ApiSecret(api_secret));
273 }
274 Self(data)
275 }
276
277 pub fn federation_id_prefix(&self) -> FederationIdPrefix {
278 self.0
279 .iter()
280 .find_map(|data| match data {
281 OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
282 OOBNotesPart::Invite { federation_id, .. } => Some(federation_id.to_prefix()),
283 _ => None,
284 })
285 .expect("Invariant violated: OOBNotes does not contain a FederationIdPrefix")
286 }
287
288 pub fn notes(&self) -> &TieredMulti<SpendableNote> {
289 self.0
290 .iter()
291 .find_map(|data| match data {
292 OOBNotesPart::Notes(notes) => Some(notes),
293 _ => None,
294 })
295 .expect("Invariant violated: OOBNotes does not contain any notes")
296 }
297
298 pub fn notes_json(&self) -> Result<serde_json::Value, serde_json::Error> {
299 let mut notes_map = serde_json::Map::new();
300 for notes in &self.0 {
301 match notes {
302 OOBNotesPart::Notes(notes) => {
303 let notes_json: serde_json::Map<String, serde_json::Value> = notes
304 .iter()
305 .map(|(amount, notes_vec)| {
306 let notes_with_nonce: Vec<serde_json::Value> = notes_vec
307 .iter()
308 .map(|note| {
309 serde_json::json!({
310 "signature": note.signature,
311 "spend_key": note.spend_key,
312 "nonce": note.nonce(),
313 })
314 })
315 .collect();
316 (
317 amount.msats.to_string(),
318 serde_json::Value::Array(notes_with_nonce),
319 )
320 })
321 .collect();
322 notes_map.insert("notes".to_string(), serde_json::Value::Object(notes_json));
323 }
324 OOBNotesPart::FederationIdPrefix(prefix) => {
325 notes_map.insert(
326 "federation_id_prefix".to_string(),
327 serde_json::to_value(prefix.to_string())?,
328 );
329 }
330 OOBNotesPart::Invite {
331 peer_apis,
332 federation_id,
333 } => {
334 let (peer_id, api) = peer_apis
335 .first()
336 .cloned()
337 .expect("Decoding makes sure peer_apis isn't empty");
338 notes_map.insert(
339 "invite".to_string(),
340 serde_json::to_value(InviteCode::new(
341 api,
342 peer_id,
343 *federation_id,
344 self.api_secret(),
345 ))?,
346 );
347 }
348 OOBNotesPart::ApiSecret(_) => { }
349 OOBNotesPart::Default { variant, bytes } => {
350 notes_map.insert(
351 format!("default_{variant}"),
352 serde_json::to_value(bytes.encode_hex::<String>())?,
353 );
354 }
355 }
356 }
357 Ok(serde_json::Value::Object(notes_map))
358 }
359
360 pub fn federation_invite(&self) -> Option<InviteCode> {
361 self.0.iter().find_map(|data| {
362 let OOBNotesPart::Invite {
363 peer_apis,
364 federation_id,
365 } = data
366 else {
367 return None;
368 };
369 let (peer_id, api) = peer_apis
370 .first()
371 .cloned()
372 .expect("Decoding makes sure peer_apis isn't empty");
373 Some(InviteCode::new(
374 api,
375 peer_id,
376 *federation_id,
377 self.api_secret(),
378 ))
379 })
380 }
381
382 fn api_secret(&self) -> Option<String> {
383 self.0.iter().find_map(|data| {
384 let OOBNotesPart::ApiSecret(api_secret) = data else {
385 return None;
386 };
387 Some(api_secret.clone())
388 })
389 }
390}
391
392impl Decodable for OOBNotes {
393 fn consensus_decode_partial<R: Read>(
394 r: &mut R,
395 _modules: &ModuleDecoderRegistry,
396 ) -> Result<Self, DecodeError> {
397 let inner =
398 Vec::<OOBNotesPart>::consensus_decode_partial(r, &ModuleDecoderRegistry::default())?;
399
400 if !inner
402 .iter()
403 .any(|data| matches!(data, OOBNotesPart::Notes(_)))
404 {
405 return Err(DecodeError::from_str(
406 "No e-cash notes were found in OOBNotes data",
407 ));
408 }
409
410 let maybe_federation_id_prefix = inner.iter().find_map(|data| match data {
411 OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
412 _ => None,
413 });
414
415 let maybe_invite = inner.iter().find_map(|data| match data {
416 OOBNotesPart::Invite {
417 federation_id,
418 peer_apis,
419 } => Some((federation_id, peer_apis)),
420 _ => None,
421 });
422
423 match (maybe_federation_id_prefix, maybe_invite) {
424 (Some(p), Some((ip, _))) => {
425 if p != ip.to_prefix() {
426 return Err(DecodeError::from_str(
427 "Inconsistent Federation ID provided in OOBNotes data",
428 ));
429 }
430 }
431 (None, None) => {
432 return Err(DecodeError::from_str(
433 "No Federation ID provided in OOBNotes data",
434 ));
435 }
436 _ => {}
437 }
438
439 if let Some((_, invite)) = maybe_invite
440 && invite.is_empty()
441 {
442 return Err(DecodeError::from_str("Invite didn't contain API endpoints"));
443 }
444
445 Ok(OOBNotes(inner))
446 }
447}
448
449const BASE64_URL_SAFE: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new(
450 &base64::alphabet::URL_SAFE,
451 base64::engine::general_purpose::PAD,
452);
453
454impl FromStr for OOBNotes {
455 type Err = anyhow::Error;
456
457 fn from_str(s: &str) -> Result<Self, Self::Err> {
459 let s: String = s.chars().filter(|&c| !c.is_whitespace()).collect();
460
461 let oob_notes_bytes = if let Ok(oob_notes_bytes) =
462 base32::decode_prefixed_bytes(FEDIMINT_PREFIX, &s)
463 {
464 oob_notes_bytes
465 } else if let Ok(oob_notes_bytes) = BASE64_URL_SAFE.decode(&s) {
466 oob_notes_bytes
467 } else if let Ok(oob_notes_bytes) = base64::engine::general_purpose::STANDARD.decode(&s) {
468 oob_notes_bytes
469 } else {
470 bail!("OOBNotes were not a well-formed base64(URL-safe) or base32 string");
471 };
472
473 let oob_notes =
474 OOBNotes::consensus_decode_whole(&oob_notes_bytes, &ModuleDecoderRegistry::default())?;
475
476 ensure!(!oob_notes.notes().is_empty(), "OOBNotes cannot be empty");
477
478 Ok(oob_notes)
479 }
480}
481
482impl Display for OOBNotes {
483 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
484 let bytes = Encodable::consensus_encode_to_vec(self);
485
486 f.write_str(&BASE64_URL_SAFE.encode(&bytes))
487 }
488}
489
490impl Serialize for OOBNotes {
491 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
492 where
493 S: serde::Serializer,
494 {
495 serializer.serialize_str(&self.to_string())
496 }
497}
498
499impl<'de> Deserialize<'de> for OOBNotes {
500 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
501 where
502 D: serde::Deserializer<'de>,
503 {
504 let s = String::deserialize(deserializer)?;
505 FromStr::from_str(&s).map_err(serde::de::Error::custom)
506 }
507}
508
509impl OOBNotes {
510 pub fn total_amount(&self) -> Amount {
512 self.notes().total_amount()
513 }
514}
515
516#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
519#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
520pub enum ReissueExternalNotesState {
521 Created,
524 Issuing,
527 Done,
529 Failed(String),
531}
532
533#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
536#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
537pub enum SpendOOBState {
538 Created,
540 UserCanceledProcessing,
543 UserCanceledSuccess,
546 UserCanceledFailure,
549 Success,
553 Refunded,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct MintOperationMeta {
561 pub variant: MintOperationMetaVariant,
562 pub amount: Amount,
563 pub extra_meta: serde_json::Value,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
567#[serde(rename_all = "snake_case")]
568pub enum MintOperationMetaVariant {
569 Reissuance {
573 #[serde(skip_serializing, default, rename = "out_point")]
575 legacy_out_point: Option<OutPoint>,
576 #[serde(default)]
578 txid: Option<TransactionId>,
579 #[serde(default)]
581 out_point_indices: Vec<u64>,
582 },
583 SpendOOB {
584 requested_amount: Amount,
585 oob_notes: OOBNotes,
586 #[serde(default)]
587 no_timeout: bool,
588 },
589}
590
591#[derive(Debug, Clone)]
592pub struct MintClientInit;
593
594const SLICE_SIZE: u64 = 10000;
595const PARALLEL_HASH_REQUESTS: usize = 10;
596const PARALLEL_SLICE_REQUESTS: usize = 10;
597
598impl MintClientInit {
599 #[allow(clippy::too_many_lines)]
600 async fn recover_from_slices(
601 &self,
602 args: &ClientModuleRecoverArgs<Self>,
603 ) -> anyhow::Result<Option<Amount>> {
604 let mut state = if let Some(state) = args
606 .db()
607 .begin_transaction_nc()
608 .await
609 .get_value(&RecoveryStateV2Key)
610 .await
611 {
612 state
613 } else {
614 let total_items = args.module_api().fetch_recovery_count().await?;
616
617 RecoveryStateV2::new(
618 total_items,
619 args.cfg().tbs_pks.tiers().copied().collect(),
620 args.module_root_secret(),
621 )
622 };
623
624 if state.next_index == state.total_items {
625 return Ok(None);
626 }
627
628 let peer_selector = PeerSelector::new(args.api().all_peers().clone());
629
630 let mut recovery_stream = futures::stream::iter(
631 (state.next_index..state.total_items).step_by(SLICE_SIZE as usize),
632 )
633 .map(move |start| {
634 let api = args.module_api().clone();
635 let end = std::cmp::min(start + SLICE_SIZE, state.total_items);
636
637 async move { (start, end, api.fetch_recovery_slice_hash(start, end).await) }
638 })
639 .buffered(PARALLEL_HASH_REQUESTS)
640 .map(move |(start, end, hash)| {
641 download_slice_with_hash(
642 args.module_api().clone(),
643 peer_selector.clone(),
644 start,
645 end,
646 hash,
647 )
648 })
649 .buffered(PARALLEL_SLICE_REQUESTS);
650
651 let secret = args.module_root_secret().clone();
652
653 loop {
654 let items = recovery_stream
655 .next()
656 .await
657 .expect("mint recovery stream finished before recovery is complete");
658
659 for item in &items {
660 match item {
661 RecoveryItem::Output { amount, nonce } => {
662 state.handle_output(*amount, *nonce, &secret);
663 }
664 RecoveryItem::Input { nonce } => {
665 state.handle_input(*nonce);
666 }
667 }
668 }
669
670 state.next_index += items.len() as u64;
671
672 let mut dbtx = args.db().begin_transaction().await;
673
674 dbtx.insert_entry(&RecoveryStateV2Key, &state).await;
675
676 if state.next_index == state.total_items {
677 let finalized = state.finalize();
679
680 let recovered_amount = finalized
682 .pending_notes
683 .iter()
684 .map(|(amount, _)| *amount)
685 .sum::<Amount>();
686
687 let blind_nonces: Vec<BlindNonce> = finalized
689 .pending_notes
690 .iter()
691 .map(|(_, req)| BlindNonce(req.blinded_message()))
692 .collect();
693
694 let outpoints = if blind_nonces.is_empty() {
696 vec![]
697 } else {
698 args.module_api()
699 .fetch_blind_nonce_outpoints(blind_nonces)
700 .await
701 .context("Failed to fetch blind nonce outpoints")?
702 };
703
704 let state_machines: Vec<MintClientStateMachines> = finalized
706 .pending_notes
707 .into_iter()
708 .zip(outpoints)
709 .map(|((amount, issuance_request), out_point)| {
710 MintClientStateMachines::Output(MintOutputStateMachine {
711 common: MintOutputCommon {
712 operation_id: OperationId::new_random(),
713 out_point_range: OutPointRange::new_single(
714 out_point.txid,
715 out_point.out_idx,
716 )
717 .expect("Can't overflow"),
718 },
719 state: MintOutputStates::Created(output::MintOutputStatesCreated {
720 amount,
721 issuance_request,
722 }),
723 })
724 })
725 .collect();
726
727 let state_machines = args.context().map_dyn(state_machines).collect();
728
729 args.context()
730 .add_state_machines_dbtx(&mut dbtx.to_ref_nc(), state_machines)
731 .await?;
732
733 for (amount, note_idx) in finalized.next_note_idx {
735 dbtx.insert_entry(&NextECashNoteIndexKey(amount), ¬e_idx.as_u64())
736 .await;
737 }
738
739 dbtx.commit_tx().await;
740
741 return Ok(Some(recovered_amount));
742 }
743
744 dbtx.commit_tx().await;
745
746 args.update_recovery_progress(RecoveryProgress {
747 complete: state.next_index.try_into().unwrap_or(u32::MAX),
748 total: state.total_items.try_into().unwrap_or(u32::MAX),
749 });
750 }
751 }
752}
753
754impl ModuleInit for MintClientInit {
755 type Common = MintCommonInit;
756
757 async fn dump_database(
758 &self,
759 dbtx: &mut DatabaseTransaction<'_>,
760 prefix_names: Vec<String>,
761 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
762 let mut mint_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
763 BTreeMap::new();
764 let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
765 prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
766 });
767
768 for table in filtered_prefixes {
769 match table {
770 DbKeyPrefix::Note => {
771 push_db_pair_items!(
772 dbtx,
773 NoteKeyPrefix,
774 NoteKey,
775 SpendableNoteUndecoded,
776 mint_client_items,
777 "Notes"
778 );
779 }
780 DbKeyPrefix::NextECashNoteIndex => {
781 push_db_pair_items!(
782 dbtx,
783 NextECashNoteIndexKeyPrefix,
784 NextECashNoteIndexKey,
785 u64,
786 mint_client_items,
787 "NextECashNoteIndex"
788 );
789 }
790 DbKeyPrefix::CancelledOOBSpend => {
791 push_db_pair_items!(
792 dbtx,
793 CancelledOOBSpendKeyPrefix,
794 CancelledOOBSpendKey,
795 (),
796 mint_client_items,
797 "CancelledOOBSpendKey"
798 );
799 }
800 DbKeyPrefix::RecoveryFinalized => {
801 if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await {
802 mint_client_items.insert("RecoveryFinalized".to_string(), Box::new(val));
803 }
804 }
805 DbKeyPrefix::RecoveryState
806 | DbKeyPrefix::ReusedNoteIndices
807 | DbKeyPrefix::RecoveryStateV2
808 | DbKeyPrefix::ExternalReservedStart
809 | DbKeyPrefix::CoreInternalReservedStart
810 | DbKeyPrefix::CoreInternalReservedEnd => {}
811 }
812 }
813
814 Box::new(mint_client_items.into_iter())
815 }
816}
817
818#[apply(async_trait_maybe_send!)]
819impl ClientModuleInit for MintClientInit {
820 type Module = MintClientModule;
821
822 fn supported_api_versions(&self) -> MultiApiVersion {
823 MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
824 .expect("no version conflicts")
825 }
826
827 async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
828 Ok(MintClientModule {
829 federation_id: *args.federation_id(),
830 cfg: args.cfg().clone(),
831 secret: args.module_root_secret().clone(),
832 secp: Secp256k1::new(),
833 notifier: args.notifier().clone(),
834 client_ctx: args.context(),
835 balance_update_sender: tokio::sync::watch::channel(()).0,
836 })
837 }
838
839 async fn recover(
840 &self,
841 args: &ClientModuleRecoverArgs<Self>,
842 snapshot: Option<&<Self::Module as ClientModule>::Backup>,
843 ) -> anyhow::Result<Option<Amount>> {
844 let mut dbtx = args.db().begin_transaction_nc().await;
845
846 if dbtx.get_value(&RecoveryStateV2Key).await.is_some() {
848 return self.recover_from_slices(args).await;
849 }
850
851 if dbtx.get_value(&RecoveryStateKey).await.is_some() {
853 return args
854 .recover_from_history::<MintRecovery>(self, snapshot)
855 .await;
856 }
857
858 if args.module_api().fetch_recovery_count().await.is_ok() {
861 self.recover_from_slices(args).await
863 } else {
864 args.recover_from_history::<MintRecovery>(self, snapshot)
866 .await
867 }
868 }
869
870 fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
871 let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = BTreeMap::new();
872 migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
873 Box::pin(migrate_to_v1(dbtx))
874 });
875 migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
876 Box::pin(async { migrate_state(active_states, inactive_states, migrate_state_to_v2) })
877 });
878
879 migrations
880 }
881
882 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
883 Some(
884 DbKeyPrefix::iter()
885 .map(|p| p as u8)
886 .chain(
887 DbKeyPrefix::ExternalReservedStart as u8
888 ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
889 )
890 .collect(),
891 )
892 }
893}
894
895#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
921pub struct MintClientModule {
922 federation_id: FederationId,
923 cfg: MintClientConfig,
924 secret: DerivableSecret,
925 secp: Secp256k1<All>,
926 notifier: ModuleNotifier<MintClientStateMachines>,
927 pub client_ctx: ClientContext<Self>,
928 balance_update_sender: tokio::sync::watch::Sender<()>,
929}
930
931impl fmt::Debug for MintClientModule {
932 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
933 f.debug_struct("MintClientModule")
934 .field("federation_id", &self.federation_id)
935 .field("cfg", &self.cfg)
936 .field("notifier", &self.notifier)
937 .field("client_ctx", &self.client_ctx)
938 .finish_non_exhaustive()
939 }
940}
941
942#[derive(Clone)]
944pub struct MintClientContext {
945 pub federation_id: FederationId,
946 pub client_ctx: ClientContext<MintClientModule>,
947 pub mint_decoder: Decoder,
948 pub tbs_pks: Tiered<AggregatePublicKey>,
949 pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>,
950 pub secret: DerivableSecret,
951 pub module_db: Database,
954 pub balance_update_sender: tokio::sync::watch::Sender<()>,
956}
957
958impl fmt::Debug for MintClientContext {
959 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
960 f.debug_struct("MintClientContext")
961 .field("federation_id", &self.federation_id)
962 .finish_non_exhaustive()
963 }
964}
965
966impl MintClientContext {
967 fn await_cancel_oob_payment(&self, operation_id: OperationId) -> BoxFuture<'static, ()> {
968 let db = self.module_db.clone();
969 Box::pin(async move {
970 db.wait_key_exists(&CancelledOOBSpendKey(operation_id))
971 .await;
972 })
973 }
974}
975
976impl Context for MintClientContext {
977 const KIND: Option<ModuleKind> = Some(KIND);
978}
979
980#[apply(async_trait_maybe_send!)]
981impl ClientModule for MintClientModule {
982 type Init = MintClientInit;
983 type Common = MintModuleTypes;
984 type Backup = EcashBackup;
985 type ModuleStateMachineContext = MintClientContext;
986 type States = MintClientStateMachines;
987
988 fn context(&self) -> Self::ModuleStateMachineContext {
989 MintClientContext {
990 federation_id: self.federation_id,
991 client_ctx: self.client_ctx.clone(),
992 mint_decoder: self.decoder(),
993 tbs_pks: self.cfg.tbs_pks.clone(),
994 peer_tbs_pks: self.cfg.peer_tbs_pks.clone(),
995 secret: self.secret.clone(),
996 module_db: self.client_ctx.module_db().clone(),
997 balance_update_sender: self.balance_update_sender.clone(),
998 }
999 }
1000
1001 fn input_fee(
1002 &self,
1003 amount: &Amounts,
1004 _input: &<Self::Common as ModuleCommon>::Input,
1005 ) -> Option<Amounts> {
1006 Some(Amounts::new_bitcoin(
1007 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
1008 ))
1009 }
1010
1011 fn output_fee(
1012 &self,
1013 amount: &Amounts,
1014 _output: &<Self::Common as ModuleCommon>::Output,
1015 ) -> Option<Amounts> {
1016 Some(Amounts::new_bitcoin(
1017 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
1018 ))
1019 }
1020
1021 #[cfg(feature = "cli")]
1022 async fn handle_cli_command(
1023 &self,
1024 args: &[std::ffi::OsString],
1025 ) -> anyhow::Result<serde_json::Value> {
1026 cli::handle_cli_command(self, args).await
1027 }
1028
1029 fn supports_backup(&self) -> bool {
1030 true
1031 }
1032
1033 async fn backup(&self) -> anyhow::Result<EcashBackup> {
1034 self.client_ctx
1035 .module_db()
1036 .autocommit(
1037 |dbtx_ctx, _| {
1038 Box::pin(async { self.prepare_plaintext_ecash_backup(dbtx_ctx).await })
1039 },
1040 None,
1041 )
1042 .await
1043 .map_err(|e| match e {
1044 AutocommitError::ClosureError { error, .. } => error,
1045 AutocommitError::CommitFailed { last_error, .. } => {
1046 anyhow!("Commit to DB failed: {last_error}")
1047 }
1048 })
1049 }
1050
1051 fn supports_being_primary(&self) -> PrimaryModuleSupport {
1052 PrimaryModuleSupport::selected(PrimaryModulePriority::HIGH, [AmountUnit::BITCOIN])
1053 }
1054
1055 async fn create_final_inputs_and_outputs(
1056 &self,
1057 dbtx: &mut DatabaseTransaction<'_>,
1058 operation_id: OperationId,
1059 unit: AmountUnit,
1060 mut input_amount: Amount,
1061 mut output_amount: Amount,
1062 ) -> anyhow::Result<(
1063 ClientInputBundle<MintInput, MintClientStateMachines>,
1064 ClientOutputBundle<MintOutput, MintClientStateMachines>,
1065 )> {
1066 let consolidation_inputs = self.consolidate_notes(dbtx).await?;
1067
1068 if unit != AmountUnit::BITCOIN {
1069 bail!("Module can only handle Bitcoin");
1070 }
1071
1072 input_amount += consolidation_inputs
1073 .iter()
1074 .map(|input| input.0.amounts.get_bitcoin())
1075 .sum();
1076
1077 output_amount += consolidation_inputs
1078 .iter()
1079 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
1080 .sum();
1081
1082 let additional_inputs = self
1083 .create_sufficient_input(dbtx, output_amount.saturating_sub(input_amount))
1084 .await?;
1085
1086 input_amount += additional_inputs
1087 .iter()
1088 .map(|input| input.0.amounts.get_bitcoin())
1089 .sum();
1090
1091 output_amount += additional_inputs
1092 .iter()
1093 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
1094 .sum();
1095
1096 let outputs = self
1097 .create_output(
1098 dbtx,
1099 operation_id,
1100 2,
1101 input_amount.saturating_sub(output_amount),
1102 )
1103 .await;
1104
1105 Ok((
1106 create_bundle_for_inputs(
1107 [consolidation_inputs, additional_inputs].concat(),
1108 operation_id,
1109 ),
1110 outputs,
1111 ))
1112 }
1113
1114 async fn await_primary_module_output(
1115 &self,
1116 operation_id: OperationId,
1117 out_point: OutPoint,
1118 ) -> anyhow::Result<()> {
1119 self.await_output_finalized(operation_id, out_point).await
1120 }
1121
1122 async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
1123 if unit != AmountUnit::BITCOIN {
1124 return Amount::ZERO;
1125 }
1126 self.get_note_counts_by_denomination(dbtx)
1127 .await
1128 .total_amount()
1129 }
1130
1131 async fn get_balances(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
1132 Amounts::new_bitcoin(
1133 <Self as ClientModule>::get_balance(self, dbtx, AmountUnit::BITCOIN).await,
1134 )
1135 }
1136
1137 async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
1138 Box::pin(tokio_stream::wrappers::WatchStream::new(
1139 self.balance_update_sender.subscribe(),
1140 ))
1141 }
1142
1143 async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
1144 let balance = ClientModule::get_balances(self, dbtx).await;
1145
1146 for (unit, amount) in balance {
1147 if Amount::from_units(0) < amount {
1148 bail!("Outstanding balance: {amount}, unit: {unit:?}");
1149 }
1150 }
1151
1152 if !self.client_ctx.get_own_active_states().await.is_empty() {
1153 bail!("Pending operations")
1154 }
1155 Ok(())
1156 }
1157
1158 async fn handle_rpc(
1159 &self,
1160 method: String,
1161 request: serde_json::Value,
1162 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
1163 Box::pin(try_stream! {
1164 match method.as_str() {
1165 "reissue_external_notes" => {
1166 let req: ReissueExternalNotesRequest = serde_json::from_value(request)?;
1167 let result = self.reissue_external_notes(req.oob_notes, req.extra_meta).await?;
1168 yield serde_json::to_value(result)?;
1169 }
1170 "subscribe_reissue_external_notes" => {
1171 let req: SubscribeReissueExternalNotesRequest = serde_json::from_value(request)?;
1172 let stream = self.subscribe_reissue_external_notes(req.operation_id).await?;
1173 for await state in stream.into_stream() {
1174 yield serde_json::to_value(state)?;
1175 }
1176 }
1177 "spend_notes" => {
1178 let req: SpendNotesRequest = serde_json::from_value(request)?;
1179 let result = self.spend_notes_with_selector(
1180 &SelectNotesWithExactAmount,
1181 req.amount,
1182 req.try_cancel_after,
1183 req.include_invite,
1184 req.extra_meta
1185 ).await?;
1186 yield serde_json::to_value(result)?;
1187 }
1188 "spend_notes_expert" => {
1189 let req: SpendNotesExpertRequest = serde_json::from_value(request)?;
1190 let result = self.spend_notes_with_selector(
1191 &SelectNotesWithAtleastAmount,
1192 req.min_amount,
1193 req.try_cancel_after,
1194 req.include_invite,
1195 req.extra_meta
1196 ).await?;
1197 yield serde_json::to_value(result)?;
1198 }
1199 "validate_notes" => {
1200 let req: ValidateNotesRequest = serde_json::from_value(request)?;
1201 let result = self.validate_notes(&req.oob_notes)?;
1202 yield serde_json::to_value(result)?;
1203 }
1204 "try_cancel_spend_notes" => {
1205 let req: TryCancelSpendNotesRequest = serde_json::from_value(request)?;
1206 let result = self.try_cancel_spend_notes(req.operation_id).await;
1207 yield serde_json::to_value(result)?;
1208 }
1209 "subscribe_spend_notes" => {
1210 let req: SubscribeSpendNotesRequest = serde_json::from_value(request)?;
1211 let stream = self.subscribe_spend_notes(req.operation_id).await?;
1212 for await state in stream.into_stream() {
1213 yield serde_json::to_value(state)?;
1214 }
1215 }
1216 "await_spend_oob_refund" => {
1217 let req: AwaitSpendOobRefundRequest = serde_json::from_value(request)?;
1218 let value = self.await_spend_oob_refund(req.operation_id).await;
1219 yield serde_json::to_value(value)?;
1220 }
1221 "note_counts_by_denomination" => {
1222 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1223 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
1224 yield serde_json::to_value(note_counts)?;
1225 }
1226 _ => {
1227 Err(anyhow::format_err!("Unknown method: {method}"))?;
1228 unreachable!()
1229 },
1230 }
1231 })
1232 }
1233}
1234
1235#[derive(Deserialize)]
1236struct ReissueExternalNotesRequest {
1237 oob_notes: OOBNotes,
1238 extra_meta: serde_json::Value,
1239}
1240
1241#[derive(Deserialize)]
1242struct SubscribeReissueExternalNotesRequest {
1243 operation_id: OperationId,
1244}
1245
1246#[derive(Deserialize)]
1249struct SpendNotesExpertRequest {
1250 min_amount: Amount,
1251 try_cancel_after: Option<Duration>,
1252 include_invite: bool,
1253 extra_meta: serde_json::Value,
1254}
1255
1256#[derive(Deserialize)]
1257struct SpendNotesRequest {
1258 amount: Amount,
1259 try_cancel_after: Option<Duration>,
1260 include_invite: bool,
1261 extra_meta: serde_json::Value,
1262}
1263
1264#[derive(Deserialize)]
1265struct ValidateNotesRequest {
1266 oob_notes: OOBNotes,
1267}
1268
1269#[derive(Deserialize)]
1270struct TryCancelSpendNotesRequest {
1271 operation_id: OperationId,
1272}
1273
1274#[derive(Deserialize)]
1275struct SubscribeSpendNotesRequest {
1276 operation_id: OperationId,
1277}
1278
1279#[derive(Deserialize)]
1280struct AwaitSpendOobRefundRequest {
1281 operation_id: OperationId,
1282}
1283
1284#[derive(thiserror::Error, Debug, Clone)]
1285pub enum ReissueExternalNotesError {
1286 #[error("Federation ID does not match")]
1287 WrongFederationId,
1288 #[error("We already reissued these notes")]
1289 AlreadyReissued,
1290}
1291
1292impl MintClientModule {
1293 async fn create_sufficient_input(
1294 &self,
1295 dbtx: &mut DatabaseTransaction<'_>,
1296 min_amount: Amount,
1297 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1298 if min_amount == Amount::ZERO {
1299 return Ok(vec![]);
1300 }
1301
1302 let selected_notes = Self::select_notes(
1303 dbtx,
1304 &SelectNotesWithAtleastAmount,
1305 min_amount,
1306 self.cfg.fee_consensus.clone(),
1307 )
1308 .await?;
1309
1310 for (amount, note) in selected_notes.iter_items() {
1311 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as sufficient input to fund a tx");
1312 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1313 }
1314
1315 let sender = self.balance_update_sender.clone();
1316 dbtx.on_commit(move || sender.send_replace(()));
1317
1318 let inputs = self.create_input_from_notes(selected_notes)?;
1319
1320 assert!(!inputs.is_empty());
1321
1322 Ok(inputs)
1323 }
1324
1325 #[deprecated(
1327 since = "0.5.0",
1328 note = "Use `get_note_counts_by_denomination` instead"
1329 )]
1330 pub async fn get_notes_tier_counts(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1331 self.get_note_counts_by_denomination(dbtx).await
1332 }
1333
1334 pub async fn get_available_notes_by_tier_counts(
1338 &self,
1339 dbtx: &mut DatabaseTransaction<'_>,
1340 counts: TieredCounts,
1341 ) -> (TieredMulti<SpendableNoteUndecoded>, TieredCounts) {
1342 dbtx.find_by_prefix(&NoteKeyPrefix)
1343 .await
1344 .fold(
1345 (TieredMulti::<SpendableNoteUndecoded>::default(), counts),
1346 |(mut notes, mut counts), (key, note)| async move {
1347 let amount = key.amount;
1348 if 0 < counts.get(amount) {
1349 counts.dec(amount);
1350 notes.push(amount, note);
1351 }
1352
1353 (notes, counts)
1354 },
1355 )
1356 .await
1357 }
1358
1359 pub async fn create_output(
1364 &self,
1365 dbtx: &mut DatabaseTransaction<'_>,
1366 operation_id: OperationId,
1367 notes_per_denomination: u16,
1368 exact_amount: Amount,
1369 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1370 if exact_amount == Amount::ZERO {
1371 return ClientOutputBundle::new(vec![], vec![]);
1372 }
1373
1374 let denominations = represent_amount(
1377 exact_amount,
1378 &self.get_note_counts_by_denomination(dbtx).await,
1379 &self.cfg.tbs_pks,
1380 notes_per_denomination,
1381 &self.cfg.fee_consensus,
1382 );
1383
1384 self.create_output_for_denominations(dbtx, operation_id, denominations)
1385 .await
1386 }
1387
1388 async fn create_exact_output(
1400 &self,
1401 dbtx: &mut DatabaseTransaction<'_>,
1402 operation_id: OperationId,
1403 amount: Amount,
1404 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1405 if amount == Amount::ZERO {
1406 return ClientOutputBundle::new(vec![], vec![]);
1407 }
1408
1409 self.create_output_for_denominations(
1410 dbtx,
1411 operation_id,
1412 self.represent_exact_amount(amount),
1413 )
1414 .await
1415 }
1416
1417 fn represent_exact_amount(&self, amount: Amount) -> TieredCounts {
1423 represent_amount(
1424 amount,
1425 &TieredCounts::default(),
1426 &self.cfg.tbs_pks,
1427 0,
1428 &FeeConsensus::zero(),
1429 )
1430 }
1431
1432 async fn create_output_for_denominations(
1433 &self,
1434 dbtx: &mut DatabaseTransaction<'_>,
1435 operation_id: OperationId,
1436 denominations: TieredCounts,
1437 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1438 let mut outputs = Vec::new();
1439 let mut issuance_requests = Vec::new();
1440
1441 for (amount, num) in denominations.iter() {
1442 for _ in 0..num {
1443 let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await;
1444
1445 debug!(
1446 %amount,
1447 "Generated issuance request"
1448 );
1449
1450 outputs.push(ClientOutput {
1451 output: MintOutput::new_v0(amount, blind_nonce),
1452 amounts: Amounts::new_bitcoin(amount),
1453 });
1454
1455 issuance_requests.push((amount, issuance_request));
1456 }
1457 }
1458
1459 let state_generator = Arc::new(move |out_point_range: OutPointRange| {
1460 assert_eq!(out_point_range.count(), issuance_requests.len());
1461 vec![MintClientStateMachines::Output(MintOutputStateMachine {
1462 common: MintOutputCommon {
1463 operation_id,
1464 out_point_range,
1465 },
1466 state: MintOutputStates::CreatedMulti(MintOutputStatesCreatedMulti {
1467 issuance_requests: out_point_range
1468 .into_iter()
1469 .map(|out_point| out_point.out_idx)
1470 .zip(issuance_requests.clone())
1471 .collect(),
1472 }),
1473 })]
1474 });
1475
1476 ClientOutputBundle::new(
1477 outputs,
1478 vec![ClientOutputSM {
1479 state_machines: state_generator,
1480 }],
1481 )
1482 }
1483
1484 pub async fn get_note_counts_by_denomination(
1486 &self,
1487 dbtx: &mut DatabaseTransaction<'_>,
1488 ) -> TieredCounts {
1489 dbtx.find_by_prefix(&NoteKeyPrefix)
1490 .await
1491 .fold(
1492 TieredCounts::default(),
1493 |mut acc, (key, _note)| async move {
1494 acc.inc(key.amount, 1);
1495 acc
1496 },
1497 )
1498 .await
1499 }
1500
1501 #[deprecated(
1503 since = "0.5.0",
1504 note = "Use `get_note_counts_by_denomination` instead"
1505 )]
1506 pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1507 self.get_note_counts_by_denomination(dbtx).await
1508 }
1509
1510 pub async fn estimate_spend_all_fees(&self) -> Amount {
1516 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1517 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
1518
1519 note_counts
1520 .iter()
1521 .filter_map(|(amount, count)| {
1522 let note_fee = self.cfg.fee_consensus.fee(amount);
1523 if note_fee < amount {
1524 note_fee.checked_mul(count as u64)
1525 } else {
1526 None
1527 }
1528 })
1529 .fold(Amount::ZERO, |acc, fee| {
1530 acc.checked_add(fee).expect("fee sum overflow")
1531 })
1532 }
1533
1534 pub async fn await_output_finalized(
1538 &self,
1539 operation_id: OperationId,
1540 out_point: OutPoint,
1541 ) -> anyhow::Result<()> {
1542 let stream = self
1543 .notifier
1544 .subscribe(operation_id)
1545 .await
1546 .filter_map(|state| async {
1547 let MintClientStateMachines::Output(state) = state else {
1548 return None;
1549 };
1550
1551 if state.common.txid() != out_point.txid
1552 || !state
1553 .common
1554 .out_point_range
1555 .out_idx_iter()
1556 .contains(&out_point.out_idx)
1557 {
1558 return None;
1559 }
1560
1561 match state.state {
1562 MintOutputStates::Succeeded(_) => Some(Ok(())),
1563 MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
1564 MintOutputStates::Failed(failed) => Some(Err(anyhow!(
1565 "Failed to finalize transaction: {}",
1566 failed.error
1567 ))),
1568 MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1569 }
1570 });
1571 pin_mut!(stream);
1572
1573 stream.next_or_pending().await
1574 }
1575
1576 pub async fn consolidate_notes(
1583 &self,
1584 dbtx: &mut DatabaseTransaction<'_>,
1585 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1586 const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1589 const MIN_NOTES_PER_TIER: usize = 4;
1591 const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1594 #[allow(clippy::assertions_on_constants)]
1596 {
1597 assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1598 }
1599
1600 let counts = self.get_note_counts_by_denomination(dbtx).await;
1601
1602 let should_consolidate = counts
1603 .iter()
1604 .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1605
1606 if !should_consolidate {
1607 return Ok(vec![]);
1608 }
1609
1610 let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1611
1612 let excessive_counts: TieredCounts = counts
1613 .iter()
1614 .map(|(amount, count)| {
1615 let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1616
1617 max_count -= take;
1618 (amount, take)
1619 })
1620 .collect();
1621
1622 let (selected_notes, unavailable) = self
1623 .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1624 .await;
1625
1626 debug_assert!(
1627 unavailable.is_empty(),
1628 "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1629 );
1630
1631 if !selected_notes.is_empty() {
1632 debug!(target: LOG_CLIENT_MODULE_MINT, note_num=selected_notes.count_items(), denominations_msats=?selected_notes.iter_items().map(|(amount, _)| amount.msats).collect::<Vec<_>>(), "Will consolidate excessive notes");
1633 }
1634
1635 let mut selected_notes_decoded = vec![];
1636 for (amount, note) in selected_notes.iter_items() {
1637 let spendable_note_decoded = note.decode()?;
1638 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1639 Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1640 .await;
1641 selected_notes_decoded.push((amount, spendable_note_decoded));
1642 }
1643
1644 let sender = self.balance_update_sender.clone();
1645 dbtx.on_commit(move || sender.send_replace(()));
1646
1647 self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1648 }
1649
1650 #[allow(clippy::type_complexity)]
1652 pub fn create_input_from_notes(
1653 &self,
1654 notes: TieredMulti<SpendableNote>,
1655 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1656 let mut inputs_and_notes = Vec::new();
1657
1658 for (amount, spendable_note) in notes.into_iter_items() {
1659 let key = self
1660 .cfg
1661 .tbs_pks
1662 .get(amount)
1663 .ok_or(anyhow!("Invalid amount tier: {amount}"))?;
1664
1665 let note = spendable_note.note();
1666
1667 if !note.verify(*key) {
1668 bail!("Invalid note");
1669 }
1670
1671 inputs_and_notes.push((
1672 ClientInput {
1673 input: MintInput::new_v0(amount, note),
1674 keys: vec![spendable_note.spend_key],
1675 amounts: Amounts::new_bitcoin(amount),
1676 },
1677 spendable_note,
1678 ));
1679 }
1680
1681 Ok(inputs_and_notes)
1682 }
1683
1684 async fn spend_notes_oob(
1685 &self,
1686 dbtx: &mut DatabaseTransaction<'_>,
1687 notes_selector: &impl NotesSelector,
1688 amount: Amount,
1689 try_cancel_after: Option<Duration>,
1690 ) -> anyhow::Result<(
1691 OperationId,
1692 Vec<MintClientStateMachines>,
1693 TieredMulti<SpendableNote>,
1694 )> {
1695 ensure!(
1696 amount > Amount::ZERO,
1697 "zero-amount out-of-band spends are not supported"
1698 );
1699
1700 let selected_notes =
1701 Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1702
1703 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1704
1705 for (amount, note) in selected_notes.iter_items() {
1706 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1707 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1708 }
1709
1710 let sender = self.balance_update_sender.clone();
1711 dbtx.on_commit(move || sender.send_replace(()));
1712
1713 let try_cancel_after = try_cancel_after.unwrap_or(OOB_SPEND_NO_TIMEOUT);
1714 let state_machines = if try_cancel_after == OOB_SPEND_NO_TIMEOUT {
1715 vec![]
1716 } else {
1717 vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1718 operation_id,
1719 state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1720 spendable_notes: selected_notes.clone().into_iter_items().collect(),
1721 timeout: fedimint_core::time::now() + try_cancel_after,
1722 }),
1723 })]
1724 };
1725
1726 Ok((operation_id, state_machines, selected_notes))
1727 }
1728
1729 async fn is_no_timeout_oob_spend(&self, operation_id: OperationId) -> anyhow::Result<bool> {
1730 let operation = self.mint_operation(operation_id).await?;
1731 let MintOperationMetaVariant::SpendOOB { no_timeout, .. } =
1732 operation.meta::<MintOperationMeta>().variant
1733 else {
1734 bail!("Operation is not a out-of-band spend");
1735 };
1736
1737 Ok(no_timeout)
1738 }
1739
1740 pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1741 if self
1742 .is_no_timeout_oob_spend(operation_id)
1743 .await
1744 .unwrap_or(false)
1745 {
1746 return SpendOOBRefund {
1747 user_triggered: false,
1748 transaction_ids: vec![],
1749 };
1750 }
1751
1752 Box::pin(
1753 self.notifier
1754 .subscribe(operation_id)
1755 .await
1756 .filter_map(|state| async {
1757 let MintClientStateMachines::OOB(state) = state else {
1758 return None;
1759 };
1760
1761 match state.state {
1762 MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1763 user_triggered: false,
1764 transaction_ids: vec![refund.refund_txid],
1765 }),
1766 MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1767 user_triggered: true,
1768 transaction_ids: vec![refund.refund_txid],
1769 }),
1770 MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1771 user_triggered: true,
1772 transaction_ids: vec![refund.refund_txid],
1773 }),
1774 MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1775 }
1776 }),
1777 )
1778 .next_or_pending()
1779 .await
1780 }
1781
1782 async fn select_notes(
1784 dbtx: &mut DatabaseTransaction<'_>,
1785 notes_selector: &impl NotesSelector,
1786 requested_amount: Amount,
1787 fee_consensus: FeeConsensus,
1788 ) -> anyhow::Result<TieredMulti<SpendableNote>> {
1789 let note_stream = dbtx
1790 .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1791 .await
1792 .map(|(key, note)| (key.amount, note));
1793
1794 notes_selector
1795 .select_notes(note_stream, requested_amount, fee_consensus)
1796 .await?
1797 .into_iter_items()
1798 .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1799 .collect::<anyhow::Result<TieredMulti<_>>>()
1800 }
1801
1802 async fn get_all_spendable_notes(
1803 dbtx: &mut DatabaseTransaction<'_>,
1804 ) -> TieredMulti<SpendableNoteUndecoded> {
1805 (dbtx
1806 .find_by_prefix(&NoteKeyPrefix)
1807 .await
1808 .map(|(key, note)| (key.amount, note))
1809 .collect::<Vec<_>>()
1810 .await)
1811 .into_iter()
1812 .collect()
1813 }
1814
1815 async fn get_next_note_index(
1816 &self,
1817 dbtx: &mut DatabaseTransaction<'_>,
1818 amount: Amount,
1819 ) -> NoteIndex {
1820 NoteIndex(
1821 dbtx.get_value(&NextECashNoteIndexKey(amount))
1822 .await
1823 .unwrap_or(0),
1824 )
1825 }
1826
1827 pub fn new_note_secret_static(
1843 secret: &DerivableSecret,
1844 amount: Amount,
1845 note_idx: NoteIndex,
1846 ) -> DerivableSecret {
1847 assert_eq!(secret.level(), 2);
1848 debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1849 secret
1850 .child_key(MINT_E_CASH_TYPE_CHILD_ID) .child_key(ChildId(note_idx.as_u64()))
1852 .child_key(ChildId(amount.msats))
1853 }
1854
1855 async fn new_note_secret(
1859 &self,
1860 amount: Amount,
1861 dbtx: &mut DatabaseTransaction<'_>,
1862 ) -> DerivableSecret {
1863 let new_idx = self.get_next_note_index(dbtx, amount).await;
1864 dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1865 .await;
1866 Self::new_note_secret_static(&self.secret, amount, new_idx)
1867 }
1868
1869 pub async fn new_ecash_note(
1870 &self,
1871 amount: Amount,
1872 dbtx: &mut DatabaseTransaction<'_>,
1873 ) -> (NoteIssuanceRequest, BlindNonce) {
1874 let secret = self.new_note_secret(amount, dbtx).await;
1875 NoteIssuanceRequest::new(&self.secp, &secret)
1876 }
1877
1878 pub async fn reissue_fee_quote(&self, oob_notes: &OOBNotes) -> anyhow::Result<FeeQuote> {
1888 let input_amount = oob_notes.total_amount();
1893 let input_fee: Amount = oob_notes
1894 .notes()
1895 .iter_items()
1896 .map(|(amount, _)| self.cfg.fee_consensus.fee(amount))
1897 .sum();
1898
1899 self.client_ctx
1900 .fee_quote(
1901 OperationId::new_random(),
1902 FeeQuoteRequest {
1903 input_amount: Amounts::new_bitcoin(input_amount),
1904 output_amount: Amounts::ZERO,
1905 input_fee: Amounts::new_bitcoin(input_fee),
1906 output_fee: Amounts::ZERO,
1907 },
1908 )
1909 .await
1910 }
1911
1912 pub async fn send_fee_quote(&self, amount: Amount) -> anyhow::Result<FeeQuote> {
1926 let amount = self.cfg.fee_consensus.round_up(amount);
1927
1928 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1929
1930 if Self::select_notes(
1934 &mut dbtx,
1935 &SelectNotesWithExactAmount,
1936 amount,
1937 FeeConsensus::zero(),
1938 )
1939 .await
1940 .is_ok()
1941 {
1942 return Ok(FeeQuote::ZERO);
1943 }
1944
1945 drop(dbtx);
1946
1947 let denominations = self.represent_exact_amount(amount);
1953
1954 let output_amount = denominations.total_amount();
1955 let output_fee: Amount = denominations
1956 .iter()
1957 .map(|(denomination, count)| self.cfg.fee_consensus.fee(denomination) * count as u64)
1958 .sum();
1959
1960 self.client_ctx
1961 .fee_quote(
1962 OperationId::new_random(),
1963 FeeQuoteRequest {
1964 input_amount: Amounts::ZERO,
1965 output_amount: Amounts::new_bitcoin(output_amount),
1966 input_fee: Amounts::ZERO,
1967 output_fee: Amounts::new_bitcoin(output_fee),
1968 },
1969 )
1970 .await
1971 }
1972
1973 pub async fn reissue_external_notes<M: Serialize + Send>(
1978 &self,
1979 oob_notes: OOBNotes,
1980 extra_meta: M,
1981 ) -> anyhow::Result<OperationId> {
1982 let notes = oob_notes.notes().clone();
1983 let federation_id_prefix = oob_notes.federation_id_prefix();
1984
1985 debug!(
1986 target: LOG_CLIENT_MODULE_MINT,
1987 notes = ?notes
1988 .iter_items()
1989 .map(|(amount, note)| (amount, note.nonce()))
1990 .collect::<Vec<_>>(),
1991 "Reissuing external notes"
1992 );
1993
1994 ensure!(
1995 notes.total_amount() > Amount::ZERO,
1996 "Reissuing zero-amount e-cash isn't supported"
1997 );
1998
1999 if federation_id_prefix != self.federation_id.to_prefix() {
2000 bail!(ReissueExternalNotesError::WrongFederationId);
2001 }
2002
2003 let operation_id = OperationId(
2004 notes
2005 .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
2006 .to_byte_array(),
2007 );
2008
2009 let amount = notes.total_amount();
2010 let mint_inputs = self.create_input_from_notes(notes)?;
2011
2012 let tx = TransactionBuilder::new().with_inputs(
2013 self.client_ctx
2014 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
2015 );
2016
2017 let extra_meta = serde_json::to_value(extra_meta)
2018 .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
2019 let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
2020 variant: MintOperationMetaVariant::Reissuance {
2021 legacy_out_point: None,
2022 txid: Some(change_range.txid()),
2023 out_point_indices: change_range
2024 .into_iter()
2025 .map(|out_point| out_point.out_idx)
2026 .collect(),
2027 },
2028 amount,
2029 extra_meta: extra_meta.clone(),
2030 };
2031
2032 self.client_ctx
2033 .finalize_and_submit_transaction(
2034 operation_id,
2035 MintCommonInit::KIND.as_str(),
2036 operation_meta_gen,
2037 tx,
2038 )
2039 .await
2040 .context(ReissueExternalNotesError::AlreadyReissued)?;
2041
2042 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2043
2044 self.client_ctx
2045 .log_event(&mut dbtx, OOBNotesReissued { amount })
2046 .await;
2047
2048 self.client_ctx
2049 .log_event(
2050 &mut dbtx,
2051 ReceivePaymentEvent {
2052 operation_id,
2053 amount,
2054 },
2055 )
2056 .await;
2057
2058 dbtx.commit_tx().await;
2059
2060 Ok(operation_id)
2061 }
2062
2063 pub async fn subscribe_reissue_external_notes(
2066 &self,
2067 operation_id: OperationId,
2068 ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
2069 let operation = self.mint_operation(operation_id).await?;
2070 let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
2071 MintOperationMetaVariant::Reissuance {
2072 legacy_out_point,
2073 txid,
2074 out_point_indices,
2075 } => {
2076 let txid = txid
2079 .or(legacy_out_point.map(|out_point| out_point.txid))
2080 .context("Empty reissuance not permitted, this should never happen")?;
2081
2082 let out_points = out_point_indices
2083 .into_iter()
2084 .map(|out_idx| OutPoint { txid, out_idx })
2085 .chain(legacy_out_point)
2086 .collect::<Vec<_>>();
2087
2088 (txid, out_points)
2089 }
2090 MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"),
2091 };
2092
2093 let client_ctx = self.client_ctx.clone();
2094
2095 Ok(self.client_ctx.outcome_or_updates(
2096 &operation,
2097 operation_id,
2098 |state| match state {
2099 ReissueExternalNotesState::Created | ReissueExternalNotesState::Issuing => false,
2100 ReissueExternalNotesState::Done | ReissueExternalNotesState::Failed(_) => true,
2101 },
2102 move || {
2103 stream! {
2104 yield ReissueExternalNotesState::Created;
2105
2106 match client_ctx
2107 .transaction_updates(operation_id)
2108 .await
2109 .await_tx_accepted(txid)
2110 .await
2111 {
2112 Ok(()) => {
2113 yield ReissueExternalNotesState::Issuing;
2114 }
2115 Err(e) => {
2116 yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
2117 return;
2118 }
2119 }
2120
2121 for out_point in out_points {
2122 if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
2123 yield ReissueExternalNotesState::Failed(e.to_string());
2124 return;
2125 }
2126 }
2127 yield ReissueExternalNotesState::Done;
2128 }}
2129 ))
2130 }
2131
2132 #[deprecated(
2145 since = "0.5.0",
2146 note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
2147 )]
2148 pub async fn spend_notes<M: Serialize + Send>(
2149 &self,
2150 min_amount: Amount,
2151 try_cancel_after: Option<Duration>,
2152 include_invite: bool,
2153 extra_meta: M,
2154 ) -> anyhow::Result<(OperationId, OOBNotes)> {
2155 self.spend_notes_with_selector(
2156 &SelectNotesWithAtleastAmount,
2157 min_amount,
2158 try_cancel_after,
2159 include_invite,
2160 extra_meta,
2161 )
2162 .await
2163 }
2164
2165 pub async fn spend_notes_with_selector<M: Serialize + Send>(
2182 &self,
2183 notes_selector: &impl NotesSelector,
2184 requested_amount: Amount,
2185 try_cancel_after: Option<Duration>,
2186 include_invite: bool,
2187 extra_meta: M,
2188 ) -> anyhow::Result<(OperationId, OOBNotes)> {
2189 let federation_id_prefix = self.federation_id.to_prefix();
2190 let extra_meta = serde_json::to_value(extra_meta)
2191 .expect("MintClientModule::spend_notes extra_meta is serializable");
2192
2193 self.client_ctx
2194 .module_db()
2195 .autocommit(
2196 |dbtx, _| {
2197 let extra_meta = extra_meta.clone();
2198 Box::pin(async {
2199 let no_timeout = try_cancel_after.is_none();
2200 let (operation_id, states, notes) = self
2201 .spend_notes_oob(
2202 dbtx,
2203 notes_selector,
2204 requested_amount,
2205 try_cancel_after,
2206 )
2207 .await?;
2208
2209 let oob_notes = if include_invite {
2210 OOBNotes::new_with_invite(
2211 notes,
2212 &self.client_ctx.get_invite_code().await,
2213 )
2214 } else {
2215 OOBNotes::new(federation_id_prefix, notes)
2216 };
2217
2218 self.client_ctx
2219 .add_state_machines_dbtx(
2220 dbtx,
2221 self.client_ctx.map_dyn(states).collect(),
2222 )
2223 .await?;
2224 self.client_ctx
2225 .add_operation_log_entry_dbtx(
2226 dbtx,
2227 operation_id,
2228 MintCommonInit::KIND.as_str(),
2229 MintOperationMeta {
2230 variant: MintOperationMetaVariant::SpendOOB {
2231 requested_amount,
2232 oob_notes: oob_notes.clone(),
2233 no_timeout,
2234 },
2235 amount: oob_notes.total_amount(),
2236 extra_meta,
2237 },
2238 )
2239 .await;
2240 self.client_ctx
2241 .log_event(
2242 dbtx,
2243 OOBNotesSpent {
2244 requested_amount,
2245 spent_amount: oob_notes.total_amount(),
2246 timeout: try_cancel_after,
2247 include_invite,
2248 },
2249 )
2250 .await;
2251
2252 self.client_ctx
2253 .log_event(
2254 dbtx,
2255 SendPaymentEvent {
2256 operation_id,
2257 amount: oob_notes.total_amount(),
2258 oob_notes: encode_prefixed(FEDIMINT_PREFIX, &oob_notes),
2259 },
2260 )
2261 .await;
2262
2263 Ok((operation_id, oob_notes))
2264 })
2265 },
2266 Some(100),
2267 )
2268 .await
2269 .map_err(|e| match e {
2270 AutocommitError::ClosureError { error, .. } => error,
2271 AutocommitError::CommitFailed { last_error, .. } => {
2272 anyhow!("Commit to DB failed: {last_error}")
2273 }
2274 })
2275 }
2276
2277 pub async fn send_oob_notes<M: Serialize + Send>(
2304 &self,
2305 amount: Amount,
2306 extra_meta: M,
2307 ) -> anyhow::Result<OOBNotes> {
2308 let amount = self.cfg.fee_consensus.round_up(amount);
2309
2310 let extra_meta = serde_json::to_value(extra_meta)
2311 .expect("MintClientModule::send_oob_notes extra_meta is serializable");
2312
2313 let oob_notes: Option<OOBNotes> = self
2315 .client_ctx
2316 .module_db()
2317 .autocommit(
2318 |dbtx, _| {
2319 let extra_meta = extra_meta.clone();
2320 Box::pin(async {
2321 self.try_spend_exact_notes_dbtx(
2322 dbtx,
2323 amount,
2324 self.federation_id,
2325 extra_meta,
2326 )
2327 .await
2328 .map(Ok::<OOBNotes, anyhow::Error>)
2329 .transpose()
2330 })
2331 },
2332 Some(100),
2333 )
2334 .await
2335 .expect("Failed to commit dbtx after 100 retries");
2336
2337 if let Some(oob_notes) = oob_notes {
2338 return Ok(oob_notes);
2339 }
2340
2341 self.client_ctx
2343 .global_api()
2344 .session_count()
2345 .await
2346 .context("Cannot reach federation to reissue notes")?;
2347
2348 let operation_id = OperationId::new_random();
2349
2350 let output_bundle = self
2357 .client_ctx
2358 .module_db()
2359 .autocommit(
2360 |dbtx, _| {
2361 Box::pin(async {
2362 Ok::<_, anyhow::Error>(
2363 self.create_exact_output(dbtx, operation_id, amount).await,
2364 )
2365 })
2366 },
2367 Some(100),
2368 )
2369 .await
2370 .expect("Failed to commit output creation after 100 retries");
2371
2372 let explicit_output_count = output_bundle.outputs().len() as u64;
2378
2379 let combined_bundle = ClientOutputBundle::new(
2381 output_bundle.outputs().to_vec(),
2382 output_bundle.sms().to_vec(),
2383 );
2384
2385 let outputs = self.client_ctx.make_client_outputs(combined_bundle);
2386
2387 let em_clone = extra_meta.clone();
2388
2389 let out_point_range = self
2391 .client_ctx
2392 .finalize_and_submit_transaction(
2393 operation_id,
2394 MintCommonInit::KIND.as_str(),
2395 move |change_range: OutPointRange| MintOperationMeta {
2396 variant: MintOperationMetaVariant::Reissuance {
2397 legacy_out_point: None,
2398 txid: Some(change_range.txid()),
2399 out_point_indices: change_range
2400 .into_iter()
2401 .map(|out_point| out_point.out_idx)
2402 .collect(),
2403 },
2404 amount,
2405 extra_meta: em_clone.clone(),
2406 },
2407 TransactionBuilder::new().with_outputs(outputs),
2408 )
2409 .await
2410 .context("Failed to submit reissuance transaction")?;
2411
2412 let txid = out_point_range.txid();
2419 let total_output_count = explicit_output_count + out_point_range.count() as u64;
2420 let all_outputs = OutPointRange::new(txid, IdxRange::from(0..total_output_count));
2421 self.client_ctx
2422 .await_primary_module_outputs(operation_id, all_outputs.into_iter().collect())
2423 .await
2424 .context("Failed to await output finalization")?;
2425
2426 Box::pin(self.send_oob_notes(amount, extra_meta)).await
2428 }
2429
2430 async fn try_spend_exact_notes_dbtx(
2433 &self,
2434 dbtx: &mut DatabaseTransaction<'_>,
2435 amount: Amount,
2436 federation_id: FederationId,
2437 extra_meta: serde_json::Value,
2438 ) -> Option<OOBNotes> {
2439 let selected_notes = Self::select_notes(
2440 dbtx,
2441 &SelectNotesWithExactAmount,
2442 amount,
2443 FeeConsensus::zero(),
2444 )
2445 .await
2446 .ok()?;
2447
2448 for (note_amount, note) in selected_notes.iter_items() {
2450 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, note_amount, note)
2451 .await;
2452 }
2453
2454 let sender = self.balance_update_sender.clone();
2455 dbtx.on_commit(move || sender.send_replace(()));
2456
2457 let operation_id = spendable_notes_to_operation_id(&selected_notes);
2458
2459 let oob_notes = OOBNotes::new(federation_id.to_prefix(), selected_notes);
2460
2461 self.client_ctx
2463 .add_operation_log_entry_dbtx(
2464 dbtx,
2465 operation_id,
2466 MintCommonInit::KIND.as_str(),
2467 MintOperationMeta {
2468 variant: MintOperationMetaVariant::SpendOOB {
2469 requested_amount: amount,
2470 oob_notes: oob_notes.clone(),
2471 no_timeout: true,
2472 },
2473 amount: oob_notes.total_amount(),
2474 extra_meta,
2475 },
2476 )
2477 .await;
2478
2479 self.client_ctx
2480 .log_event(
2481 dbtx,
2482 SendPaymentEvent {
2483 operation_id,
2484 amount: oob_notes.total_amount(),
2485 oob_notes: encode_prefixed(FEDIMINT_PREFIX, &oob_notes),
2486 },
2487 )
2488 .await;
2489
2490 Some(oob_notes)
2491 }
2492
2493 pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> {
2499 let federation_id_prefix = oob_notes.federation_id_prefix();
2500 let notes = oob_notes.notes().clone();
2501
2502 if federation_id_prefix != self.federation_id.to_prefix() {
2503 bail!("Federation ID does not match");
2504 }
2505
2506 let tbs_pks = &self.cfg.tbs_pks;
2507
2508 for (idx, (amt, snote)) in notes.iter_items().enumerate() {
2509 let key = tbs_pks
2510 .get(amt)
2511 .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
2512
2513 let note = snote.note();
2514 if !note.verify(*key) {
2515 bail!("Note {idx} has an invalid federation signature");
2516 }
2517
2518 let expected_nonce = Nonce(snote.spend_key.public_key());
2519 if note.nonce != expected_nonce {
2520 bail!("Note {idx} cannot be spent using the supplied spend key");
2521 }
2522 }
2523
2524 Ok(notes.total_amount())
2525 }
2526
2527 pub async fn check_note_spent(&self, oob_notes: &OOBNotes) -> anyhow::Result<bool> {
2533 use crate::api::MintFederationApi;
2534
2535 let api_client = self.client_ctx.module_api();
2536 let any_spent = try_join_all(oob_notes.notes().iter().flat_map(|(_, notes)| {
2537 notes
2538 .iter()
2539 .map(|note| api_client.check_note_spent(note.nonce()))
2540 }))
2541 .await?
2542 .into_iter()
2543 .any(|spent| spent);
2544
2545 Ok(any_spent)
2546 }
2547
2548 pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
2553 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2554 dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
2555 .await;
2556 if let Err(e) = dbtx.commit_tx_result().await {
2557 warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
2558 }
2559 }
2560
2561 pub async fn subscribe_spend_notes(
2564 &self,
2565 operation_id: OperationId,
2566 ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
2567 let operation = self.mint_operation(operation_id).await?;
2568 let MintOperationMetaVariant::SpendOOB { no_timeout, .. } =
2569 operation.meta::<MintOperationMeta>().variant
2570 else {
2571 bail!("Operation is not a out-of-band spend");
2572 };
2573
2574 let client_ctx = self.client_ctx.clone();
2575
2576 Ok(self.client_ctx.outcome_or_updates(
2577 &operation,
2578 operation_id,
2579 |state| match state {
2580 SpendOOBState::Created | SpendOOBState::UserCanceledProcessing => false,
2581 SpendOOBState::UserCanceledSuccess
2582 | SpendOOBState::UserCanceledFailure
2583 | SpendOOBState::Success
2584 | SpendOOBState::Refunded => true,
2585 },
2586 move || {
2587 stream! {
2588 yield SpendOOBState::Created;
2589
2590 if no_timeout {
2591 yield SpendOOBState::Success;
2592 return;
2593 }
2594
2595 let self_ref = client_ctx.self_ref();
2596
2597 let refund = self_ref
2598 .await_spend_oob_refund(operation_id)
2599 .await;
2600
2601 if refund.user_triggered {
2602 yield SpendOOBState::UserCanceledProcessing;
2603 }
2604
2605 let mut success = true;
2606
2607 for txid in refund.transaction_ids {
2608 debug!(
2609 target: LOG_CLIENT_MODULE_MINT,
2610 %txid,
2611 operation_id=%operation_id.fmt_short(),
2612 "Waiting for oob refund txid"
2613 );
2614 if client_ctx
2615 .transaction_updates(operation_id)
2616 .await
2617 .await_tx_accepted(txid)
2618 .await.is_err() {
2619 success = false;
2620 }
2621 }
2622
2623 debug!(
2624 target: LOG_CLIENT_MODULE_MINT,
2625 operation_id=%operation_id.fmt_short(),
2626 %success,
2627 "Done waiting for all refund oob txids"
2628 );
2629
2630 match (refund.user_triggered, success) {
2631 (true, true) => {
2632 yield SpendOOBState::UserCanceledSuccess;
2633 },
2634 (true, false) => {
2635 yield SpendOOBState::UserCanceledFailure;
2636 },
2637 (false, true) => {
2638 yield SpendOOBState::Refunded;
2639 },
2640 (false, false) => {
2641 yield SpendOOBState::Success;
2642 }
2643 }
2644 }
2645 },
2646 ))
2647 }
2648
2649 async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
2650 let operation = self.client_ctx.get_operation(operation_id).await?;
2651
2652 if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
2653 bail!("Operation is not a mint operation");
2654 }
2655
2656 Ok(operation)
2657 }
2658
2659 async fn delete_spendable_note(
2660 client_ctx: &ClientContext<MintClientModule>,
2661 dbtx: &mut DatabaseTransaction<'_>,
2662 amount: Amount,
2663 note: &SpendableNote,
2664 ) {
2665 client_ctx
2666 .log_event(
2667 dbtx,
2668 NoteSpent {
2669 nonce: note.nonce(),
2670 },
2671 )
2672 .await;
2673 dbtx.remove_entry(&NoteKey {
2674 amount,
2675 nonce: note.nonce(),
2676 })
2677 .await
2678 .expect("Must deleted existing spendable note");
2679 }
2680
2681 pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> {
2682 let db = self.client_ctx.module_db().clone();
2683
2684 Ok(db
2685 .autocommit(
2686 |dbtx, _| {
2687 Box::pin(async {
2688 Ok::<DerivableSecret, anyhow::Error>(
2689 self.new_note_secret(amount, dbtx).await,
2690 )
2691 })
2692 },
2693 None,
2694 )
2695 .await?)
2696 }
2697
2698 pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
2701 self.client_ctx
2702 .module_db()
2703 .begin_transaction_nc()
2704 .await
2705 .get_value(&ReusedNoteIndices)
2706 .await
2707 .unwrap_or_default()
2708 .into_iter()
2709 .map(|(amount, note_idx)| {
2710 let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
2711 let (request, blind_nonce) =
2712 NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
2713 (amount, request, blind_nonce)
2714 })
2715 .collect()
2716 }
2717}
2718
2719pub fn spendable_notes_to_operation_id(
2720 spendable_selected_notes: &TieredMulti<SpendableNote>,
2721) -> OperationId {
2722 OperationId(
2723 spendable_selected_notes
2724 .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
2725 .to_byte_array(),
2726 )
2727}
2728
2729#[derive(Debug, Serialize, Deserialize, Clone)]
2730#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
2731pub struct SpendOOBRefund {
2732 pub user_triggered: bool,
2733 pub transaction_ids: Vec<TransactionId>,
2736}
2737
2738#[apply(async_trait_maybe_send!)]
2741pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
2742 async fn select_notes(
2745 &self,
2746 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2748 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2749 requested_amount: Amount,
2750 fee_consensus: FeeConsensus,
2751 ) -> anyhow::Result<TieredMulti<Note>>;
2752}
2753
2754pub struct SelectNotesWithAtleastAmount;
2760
2761#[apply(async_trait_maybe_send!)]
2762impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
2763 async fn select_notes(
2764 &self,
2765 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2766 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2767 requested_amount: Amount,
2768 fee_consensus: FeeConsensus,
2769 ) -> anyhow::Result<TieredMulti<Note>> {
2770 Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
2771 }
2772}
2773
2774pub struct SelectNotesWithExactAmount;
2778
2779#[apply(async_trait_maybe_send!)]
2780impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
2781 async fn select_notes(
2782 &self,
2783 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2784 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2785 requested_amount: Amount,
2786 fee_consensus: FeeConsensus,
2787 ) -> anyhow::Result<TieredMulti<Note>> {
2788 let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
2789
2790 if notes.total_amount() != requested_amount {
2791 bail!(
2792 "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}",
2793 requested_amount,
2794 notes.total_amount()
2795 );
2796 }
2797
2798 Ok(notes)
2799 }
2800}
2801
2802async fn select_notes_from_stream<Note>(
2808 stream: impl futures::Stream<Item = (Amount, Note)>,
2809 requested_amount: Amount,
2810 fee_consensus: FeeConsensus,
2811) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
2812 if requested_amount == Amount::ZERO {
2813 return Ok(TieredMulti::default());
2814 }
2815 let mut stream = Box::pin(stream);
2816 let mut selected = vec![];
2817 let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
2822 let mut pending_amount = requested_amount;
2823 let mut previous_amount: Option<Amount> = None; loop {
2825 if let Some((note_amount, note)) = stream.next().await {
2826 assert!(
2827 previous_amount.is_none_or(|previous| previous >= note_amount),
2828 "notes are not sorted in descending order"
2829 );
2830 previous_amount = Some(note_amount);
2831
2832 if note_amount <= fee_consensus.fee(note_amount) {
2833 continue;
2834 }
2835
2836 match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2837 Ordering::Less => {
2838 pending_amount += fee_consensus.fee(note_amount);
2840 pending_amount -= note_amount;
2841 selected.push((note_amount, note));
2842 }
2843 Ordering::Greater => {
2844 last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2848 }
2849 Ordering::Equal => {
2850 selected.push((note_amount, note));
2852
2853 let notes: TieredMulti<Note> = selected.into_iter().collect();
2854
2855 assert!(
2856 notes.total_amount().msats
2857 >= requested_amount.msats
2858 + notes
2859 .iter()
2860 .map(|note| fee_consensus.fee(note.0))
2861 .sum::<Amount>()
2862 .msats
2863 );
2864
2865 return Ok(notes);
2866 }
2867 }
2868 } else {
2869 assert!(pending_amount > Amount::ZERO);
2870 if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2871 selected.truncate(checkpoint);
2874 selected.push((big_note_amount, big_note));
2876
2877 let notes: TieredMulti<Note> = selected.into_iter().collect();
2878
2879 assert!(
2880 notes.total_amount().msats
2881 >= requested_amount.msats
2882 + notes
2883 .iter()
2884 .map(|note| fee_consensus.fee(note.0))
2885 .sum::<Amount>()
2886 .msats
2887 );
2888
2889 return Ok(notes);
2891 }
2892
2893 let total_amount = requested_amount.saturating_sub(pending_amount);
2894 return Err(InsufficientBalanceError {
2896 requested_amount,
2897 total_amount,
2898 });
2899 }
2900 }
2901}
2902
2903#[derive(Debug, Clone, Error)]
2904pub struct InsufficientBalanceError {
2905 pub requested_amount: Amount,
2906 pub total_amount: Amount,
2907}
2908
2909impl std::fmt::Display for InsufficientBalanceError {
2910 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2911 write!(
2912 f,
2913 "Insufficient balance: requested {} but only {} available",
2914 self.requested_amount, self.total_amount
2915 )
2916 }
2917}
2918
2919#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2921enum MintRestoreStates {
2922 #[encodable_default]
2923 Default { variant: u64, bytes: Vec<u8> },
2924}
2925
2926#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2928pub struct MintRestoreStateMachine {
2929 operation_id: OperationId,
2930 state: MintRestoreStates,
2931}
2932
2933#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2934pub enum MintClientStateMachines {
2935 Output(MintOutputStateMachine),
2936 Input(MintInputStateMachine),
2937 OOB(MintOOBStateMachine),
2938 Restore(MintRestoreStateMachine),
2940}
2941
2942impl IntoDynInstance for MintClientStateMachines {
2943 type DynType = DynState;
2944
2945 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2946 DynState::from_typed(instance_id, self)
2947 }
2948}
2949
2950impl State for MintClientStateMachines {
2951 type ModuleContext = MintClientContext;
2952
2953 fn transitions(
2954 &self,
2955 context: &Self::ModuleContext,
2956 global_context: &DynGlobalClientContext,
2957 ) -> Vec<StateTransition<Self>> {
2958 match self {
2959 MintClientStateMachines::Output(issuance_state) => {
2960 sm_enum_variant_translation!(
2961 issuance_state.transitions(context, global_context),
2962 MintClientStateMachines::Output
2963 )
2964 }
2965 MintClientStateMachines::Input(redemption_state) => {
2966 sm_enum_variant_translation!(
2967 redemption_state.transitions(context, global_context),
2968 MintClientStateMachines::Input
2969 )
2970 }
2971 MintClientStateMachines::OOB(oob_state) => {
2972 sm_enum_variant_translation!(
2973 oob_state.transitions(context, global_context),
2974 MintClientStateMachines::OOB
2975 )
2976 }
2977 MintClientStateMachines::Restore(_) => {
2978 sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
2979 }
2980 }
2981 }
2982
2983 fn operation_id(&self) -> OperationId {
2984 match self {
2985 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
2986 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
2987 MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
2988 MintClientStateMachines::Restore(r) => r.operation_id,
2989 }
2990 }
2991
2992 fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
2993 match self {
2994 MintClientStateMachines::Output(s) => s.fmt_visualization(f, indent),
2995 MintClientStateMachines::Input(s) => s.fmt_visualization(f, indent),
2996 MintClientStateMachines::OOB(s) => s.fmt_visualization(f, indent),
2997 MintClientStateMachines::Restore(_) => write!(f, "{indent}{self:?}"),
2998 }
2999 }
3000}
3001
3002#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
3005pub struct SpendableNote {
3006 pub signature: tbs::Signature,
3007 pub spend_key: Keypair,
3008}
3009
3010impl fmt::Debug for SpendableNote {
3011 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3012 f.debug_struct("SpendableNote")
3013 .field("nonce", &self.nonce())
3014 .field("signature", &self.signature)
3015 .field("spend_key", &self.spend_key)
3016 .finish()
3017 }
3018}
3019impl fmt::Display for SpendableNote {
3020 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3021 write!(f, "{}", self.nonce().fmt_short())
3022 }
3023}
3024
3025impl SpendableNote {
3026 pub fn nonce(&self) -> Nonce {
3027 Nonce(self.spend_key.public_key())
3028 }
3029
3030 fn note(&self) -> Note {
3031 Note {
3032 nonce: self.nonce(),
3033 signature: self.signature,
3034 }
3035 }
3036
3037 pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
3038 SpendableNoteUndecoded {
3039 signature: self
3040 .signature
3041 .consensus_encode_to_vec()
3042 .try_into()
3043 .expect("Encoded size always correct"),
3044 spend_key: self.spend_key,
3045 }
3046 }
3047}
3048
3049#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
3061pub struct SpendableNoteUndecoded {
3062 #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
3065 pub signature: [u8; 48],
3066 pub spend_key: Keypair,
3067}
3068
3069impl fmt::Display for SpendableNoteUndecoded {
3070 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3071 write!(f, "{}", self.nonce().fmt_short())
3072 }
3073}
3074
3075impl fmt::Debug for SpendableNoteUndecoded {
3076 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3077 f.debug_struct("SpendableNote")
3078 .field("nonce", &self.nonce())
3079 .field("signature", &"[raw]")
3080 .field("spend_key", &self.spend_key)
3081 .finish()
3082 }
3083}
3084
3085impl SpendableNoteUndecoded {
3086 fn nonce(&self) -> Nonce {
3087 Nonce(self.spend_key.public_key())
3088 }
3089
3090 pub fn decode(self) -> anyhow::Result<SpendableNote> {
3091 Ok(SpendableNote {
3092 signature: Decodable::consensus_decode_partial_from_finite_reader(
3093 &mut self.signature.as_slice(),
3094 &ModuleRegistry::default(),
3095 )?,
3096 spend_key: self.spend_key,
3097 })
3098 }
3099}
3100
3101#[derive(
3107 Copy,
3108 Clone,
3109 Debug,
3110 Serialize,
3111 Deserialize,
3112 PartialEq,
3113 Eq,
3114 Encodable,
3115 Decodable,
3116 Default,
3117 PartialOrd,
3118 Ord,
3119)]
3120pub struct NoteIndex(u64);
3121
3122impl NoteIndex {
3123 pub fn next(self) -> Self {
3124 Self(self.0 + 1)
3125 }
3126
3127 fn prev(self) -> Option<Self> {
3128 self.0.checked_sub(0).map(Self)
3129 }
3130
3131 pub fn as_u64(self) -> u64 {
3132 self.0
3133 }
3134
3135 #[allow(unused)]
3139 pub fn from_u64(v: u64) -> Self {
3140 Self(v)
3141 }
3142
3143 pub fn advance(&mut self) {
3144 *self = self.next();
3145 }
3146}
3147
3148impl std::fmt::Display for NoteIndex {
3149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3150 self.0.fmt(f)
3151 }
3152}
3153
3154struct OOBSpendTag;
3155
3156impl sha256t::Tag for OOBSpendTag {
3157 fn engine() -> sha256::HashEngine {
3158 let mut engine = sha256::HashEngine::default();
3159 engine.input(b"oob-spend");
3160 engine
3161 }
3162}
3163
3164struct OOBReissueTag;
3165
3166impl sha256t::Tag for OOBReissueTag {
3167 fn engine() -> sha256::HashEngine {
3168 let mut engine = sha256::HashEngine::default();
3169 engine.input(b"oob-reissue");
3170 engine
3171 }
3172}
3173
3174pub fn represent_amount<K>(
3180 amount: Amount,
3181 current_denominations: &TieredCounts,
3182 tiers: &Tiered<K>,
3183 denomination_sets: u16,
3184 fee_consensus: &FeeConsensus,
3185) -> TieredCounts {
3186 let mut remaining_amount = amount;
3187 let mut denominations = TieredCounts::default();
3188
3189 for tier in tiers.tiers() {
3191 let notes = current_denominations.get(*tier);
3192 let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
3193 let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
3194
3195 let add_notes = min(possible_notes, missing_notes);
3196 denominations.inc(*tier, add_notes as usize);
3197 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
3198 }
3199
3200 for tier in tiers.tiers().rev() {
3202 let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
3203 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
3204 denominations.inc(*tier, res as usize);
3205 }
3206
3207 let represented: u64 = denominations
3208 .iter()
3209 .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
3210 .sum();
3211
3212 assert!(represented <= amount.msats);
3213 assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
3214
3215 denominations
3216}
3217
3218pub(crate) fn create_bundle_for_inputs(
3219 inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
3220 operation_id: OperationId,
3221) -> ClientInputBundle<MintInput, MintClientStateMachines> {
3222 let mut inputs = Vec::new();
3223 let mut input_states = Vec::new();
3224
3225 for (input, spendable_note) in inputs_and_notes {
3226 input_states.push((input.amounts.clone(), spendable_note));
3227 inputs.push(input);
3228 }
3229
3230 let input_sm = Arc::new(move |out_point_range: OutPointRange| {
3231 debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
3232
3233 vec![MintClientStateMachines::Input(MintInputStateMachine {
3234 common: MintInputCommon {
3235 operation_id,
3236 out_point_range,
3237 },
3238 state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
3239 notes: input_states
3240 .iter()
3241 .map(|(amounts, note)| (amounts.expect_only_bitcoin(), *note))
3242 .collect(),
3243 }),
3244 })]
3245 });
3246
3247 ClientInputBundle::new(
3248 inputs,
3249 vec![ClientInputSM {
3250 state_machines: input_sm,
3251 }],
3252 )
3253}
3254
3255#[cfg(test)]
3256mod tests {
3257 use std::fmt::Display;
3258 use std::str::FromStr;
3259
3260 use bitcoin_hashes::Hash;
3261 use fedimint_core::base32::FEDIMINT_PREFIX;
3262 use fedimint_core::config::FederationId;
3263 use fedimint_core::encoding::Decodable;
3264 use fedimint_core::invite_code::InviteCode;
3265 use fedimint_core::module::registry::ModuleRegistry;
3266 use fedimint_core::{
3267 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
3268 };
3269 use fedimint_mint_common::config::FeeConsensus;
3270 use itertools::Itertools;
3271 use serde_json::json;
3272
3273 use crate::{
3274 MintOperationMetaVariant, OOBNotes, OOBNotesPart, SpendableNote, SpendableNoteUndecoded,
3275 represent_amount, select_notes_from_stream,
3276 };
3277
3278 #[test]
3279 fn represent_amount_targets_denomination_sets() {
3280 fn tiers(tiers: Vec<u64>) -> Tiered<()> {
3281 tiers
3282 .into_iter()
3283 .map(|tier| (Amount::from_sats(tier), ()))
3284 .collect()
3285 }
3286
3287 fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
3288 TieredCounts::from_iter(denominations)
3289 }
3290
3291 let starting = notes(vec![
3292 (Amount::from_sats(1), 1),
3293 (Amount::from_sats(2), 3),
3294 (Amount::from_sats(3), 2),
3295 ])
3296 .summary();
3297 let tiers = tiers(vec![1, 2, 3, 4]);
3298
3299 assert_eq!(
3301 represent_amount(
3302 Amount::from_sats(6),
3303 &starting,
3304 &tiers,
3305 3,
3306 &FeeConsensus::zero()
3307 ),
3308 denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
3309 );
3310
3311 assert_eq!(
3313 represent_amount(
3314 Amount::from_sats(6),
3315 &starting,
3316 &tiers,
3317 2,
3318 &FeeConsensus::zero()
3319 ),
3320 denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
3321 );
3322 }
3323
3324 #[test_log::test(tokio::test)]
3325 async fn select_notes_avg_test() {
3326 let max_amount = Amount::from_sats(1_000_000);
3327 let tiers = Tiered::gen_denominations(2, max_amount);
3328 let tiered = represent_amount::<()>(
3329 max_amount,
3330 &TieredCounts::default(),
3331 &tiers,
3332 3,
3333 &FeeConsensus::zero(),
3334 );
3335
3336 let mut total_notes = 0;
3337 for multiplier in 1..100 {
3338 let stream = reverse_sorted_note_stream(tiered.iter().collect());
3339 let select = select_notes_from_stream(
3340 stream,
3341 Amount::from_sats(multiplier * 1000),
3342 FeeConsensus::zero(),
3343 )
3344 .await;
3345 total_notes += select.unwrap().into_iter_items().count();
3346 }
3347 assert_eq!(total_notes / 100, 10);
3348 }
3349
3350 #[test_log::test(tokio::test)]
3351 async fn select_notes_returns_exact_amount_with_minimum_notes() {
3352 let f = || {
3353 reverse_sorted_note_stream(vec![
3354 (Amount::from_sats(1), 10),
3355 (Amount::from_sats(5), 10),
3356 (Amount::from_sats(20), 10),
3357 ])
3358 };
3359 assert_eq!(
3360 select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
3361 .await
3362 .unwrap(),
3363 notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
3364 );
3365 assert_eq!(
3366 select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
3367 .await
3368 .unwrap(),
3369 notes(vec![(Amount::from_sats(20), 1)])
3370 );
3371 }
3372
3373 #[test_log::test(tokio::test)]
3374 async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
3375 let stream = reverse_sorted_note_stream(vec![
3376 (Amount::from_sats(1), 1),
3377 (Amount::from_sats(5), 5),
3378 (Amount::from_sats(20), 5),
3379 ]);
3380 assert_eq!(
3381 select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
3382 .await
3383 .unwrap(),
3384 notes(vec![(Amount::from_sats(5), 2)])
3385 );
3386 }
3387
3388 #[test_log::test(tokio::test)]
3389 async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
3390 let stream = reverse_sorted_note_stream(vec![
3391 (Amount::from_sats(1), 3),
3392 (Amount::from_sats(5), 3),
3393 (Amount::from_sats(20), 2),
3394 ]);
3395 assert_eq!(
3396 select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
3397 .await
3398 .unwrap(),
3399 notes(vec![(Amount::from_sats(20), 2)])
3400 );
3401 }
3402
3403 #[test_log::test(tokio::test)]
3404 async fn select_notes_returns_error_if_amount_is_too_large() {
3405 let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
3406 let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
3407 .await
3408 .unwrap_err();
3409 assert_eq!(error.total_amount, Amount::from_sats(10));
3410 }
3411
3412 fn reverse_sorted_note_stream(
3413 notes: Vec<(Amount, usize)>,
3414 ) -> impl futures::Stream<Item = (Amount, String)> {
3415 futures::stream::iter(
3416 notes
3417 .into_iter()
3418 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
3420 .sorted()
3421 .rev(),
3422 )
3423 }
3424
3425 fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
3426 notes
3427 .into_iter()
3428 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
3429 .collect()
3430 }
3431
3432 #[test]
3433 fn decoding_empty_oob_notes_fails() {
3434 let empty_oob_notes =
3435 OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
3436 let oob_notes_string = empty_oob_notes.to_string();
3437
3438 let res = oob_notes_string.parse::<OOBNotes>();
3439
3440 assert!(res.is_err(), "An empty OOB notes string should not parse");
3441 }
3442
3443 fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
3444 where
3445 T: FromStr + Display + crate::Encodable + crate::Decodable,
3446 <T as FromStr>::Err: std::fmt::Debug,
3447 F: Fn(T),
3448 {
3449 let data_parsed = data.to_string().parse().expect("Deserialization failed");
3450
3451 assertions(data_parsed);
3452
3453 let data_parsed = crate::base32::encode_prefixed(FEDIMINT_PREFIX, &data)
3454 .parse()
3455 .expect("Deserialization failed");
3456
3457 assertions(data_parsed);
3458
3459 assertions(data);
3460 }
3461
3462 #[test]
3463 fn notes_encode_decode() {
3464 let federation_id_1 =
3465 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
3466 let federation_id_prefix_1 = federation_id_1.to_prefix();
3467 let federation_id_2 =
3468 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
3469 let federation_id_prefix_2 = federation_id_2.to_prefix();
3470
3471 let notes = vec![(
3472 Amount::from_sats(1),
3473 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
3474 )]
3475 .into_iter()
3476 .collect::<TieredMulti<_>>();
3477
3478 let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
3480 test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
3481 assert_eq!(oob_notes.notes(), ¬es);
3482 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3483 assert_eq!(oob_notes.federation_invite(), None);
3484 });
3485
3486 let invite = InviteCode::new(
3488 "wss://foo.bar".parse().unwrap(),
3489 PeerId::from(0),
3490 federation_id_1,
3491 None,
3492 );
3493 let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
3494 test_roundtrip_serialize_str(notes_invite, |oob_notes| {
3495 assert_eq!(oob_notes.notes(), ¬es);
3496 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3497 assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
3498 });
3499
3500 let notes_no_prefix = OOBNotes(vec![
3503 OOBNotesPart::Notes(notes.clone()),
3504 OOBNotesPart::Invite {
3505 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
3506 federation_id: federation_id_1,
3507 },
3508 ]);
3509 test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
3510 assert_eq!(oob_notes.notes(), ¬es);
3511 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3512 });
3513
3514 let notes_inconsistent = OOBNotes(vec![
3516 OOBNotesPart::Notes(notes),
3517 OOBNotesPart::Invite {
3518 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
3519 federation_id: federation_id_1,
3520 },
3521 OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
3522 ]);
3523 let notes_inconsistent_str = notes_inconsistent.to_string();
3524 assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
3525 }
3526
3527 #[test]
3528 fn spendable_note_undecoded_sanity() {
3529 #[allow(clippy::single_element_loop)]
3531 for note_hex in [
3532 "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
3533 ] {
3534 let note =
3535 SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
3536 let note_undecoded =
3537 SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
3538 .unwrap()
3539 .decode()
3540 .unwrap();
3541 assert_eq!(note, note_undecoded,);
3542 assert_eq!(
3543 serde_json::to_string(¬e).unwrap(),
3544 serde_json::to_string(¬e_undecoded).unwrap(),
3545 );
3546 }
3547 }
3548
3549 #[test]
3550 fn reissuance_meta_compatibility_02_03() {
3551 let dummy_outpoint = OutPoint {
3552 txid: TransactionId::all_zeros(),
3553 out_idx: 0,
3554 };
3555
3556 let old_meta_json = json!({
3557 "reissuance": {
3558 "out_point": dummy_outpoint
3559 }
3560 });
3561
3562 let old_meta: MintOperationMetaVariant =
3563 serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
3564 assert_eq!(
3565 old_meta,
3566 MintOperationMetaVariant::Reissuance {
3567 legacy_out_point: Some(dummy_outpoint),
3568 txid: None,
3569 out_point_indices: vec![],
3570 }
3571 );
3572
3573 let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
3574 legacy_out_point: None,
3575 txid: Some(dummy_outpoint.txid),
3576 out_point_indices: vec![0],
3577 })
3578 .expect("serializing always works");
3579 assert_eq!(
3580 new_meta_json,
3581 json!({
3582 "reissuance": {
3583 "txid": dummy_outpoint.txid,
3584 "out_point_indices": [dummy_outpoint.out_idx],
3585 }
3586 })
3587 );
3588 }
3589
3590 #[test]
3591 fn spend_oob_meta_no_timeout_defaults_to_false() {
3592 let notes = vec![(
3593 Amount::from_sats(1),
3594 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
3595 )]
3596 .into_iter()
3597 .collect::<TieredMulti<_>>();
3598 let oob_notes = OOBNotes::new(FederationId::dummy().to_prefix(), notes);
3599 let mut old_meta_json = serde_json::to_value(MintOperationMetaVariant::SpendOOB {
3600 requested_amount: Amount::from_sats(42),
3601 oob_notes: oob_notes.clone(),
3602 no_timeout: false,
3603 })
3604 .expect("serializing always works");
3605 old_meta_json
3606 .get_mut("spend_o_o_b")
3607 .expect("spend OOB variant should serialize as spend_o_o_b")
3608 .as_object_mut()
3609 .expect("spend OOB variant should serialize to an object")
3610 .remove("no_timeout");
3611 assert_eq!(
3612 old_meta_json,
3613 json!({
3614 "spend_o_o_b": {
3615 "requested_amount": Amount::from_sats(42),
3616 "oob_notes": oob_notes.clone(),
3617 }
3618 })
3619 );
3620
3621 let old_meta: MintOperationMetaVariant =
3622 serde_json::from_value(old_meta_json).expect("parsing old spend OOB meta failed");
3623 assert_eq!(
3624 old_meta,
3625 MintOperationMetaVariant::SpendOOB {
3626 requested_amount: Amount::from_sats(42),
3627 oob_notes,
3628 no_timeout: false,
3629 }
3630 );
3631 }
3632}