Skip to main content

fedimint_mint_client/
lib.rs

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
12// Backup and restore logic
13pub mod backup;
14/// Modularized Cli for sending and receiving out-of-band ecash
15#[cfg(feature = "cli")]
16mod cli;
17/// Database keys used throughout the mint client module
18pub mod client_db;
19/// Error types of the mint client
20pub mod error;
21/// FFI for the mint client module
22#[cfg(feature = "uniffi")]
23pub mod ffi;
24/// State machines for mint inputs
25mod input;
26/// State machines for out-of-band transmitted e-cash notes
27mod oob;
28/// State machines for mint outputs
29pub mod output;
30
31pub mod events;
32
33/// API client impl for mint-specific requests
34pub mod api;
35
36pub mod repair_wallet;
37
38pub mod visualize;
39
40use std::cmp::{Ordering, min};
41use std::collections::{BTreeMap, BTreeSet};
42use std::fmt;
43use std::fmt::{Display, Formatter};
44use std::io::Read;
45use std::str::FromStr;
46use std::sync::{Arc, RwLock};
47use std::time::Duration;
48
49use anyhow::{Context as _, anyhow, bail};
50use api::MintFederationApi;
51use async_stream::{stream, try_stream};
52use backup::recovery::{MintRecovery, RecoveryStateV2};
53use base64::Engine as _;
54use bitcoin_hashes::{Hash, HashEngine as BitcoinHashEngine, sha256, sha256t};
55use client_db::{
56    DbKeyPrefix, NoteKeyPrefix, RecoveryFinalizedKey, RecoveryStateKey, RecoveryStateV2Key,
57    ReusedNoteIndices, migrate_state_to_v2, migrate_to_v1,
58};
59use events::{NoteSpent, OOBNotesReissued, OOBNotesSpent, ReceivePaymentEvent, SendPaymentEvent};
60use fedimint_api_client::api::{DynModuleApi, FederationResult};
61use fedimint_client_module::db::{ClientModuleMigrationFn, migrate_state};
62pub use fedimint_client_module::error::InsufficientBalanceError;
63use fedimint_client_module::error::{OperationLookupError, TransactionSubmitError};
64use fedimint_client_module::module::init::{
65    ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs, RecoveryMode,
66};
67use fedimint_client_module::module::recovery::RecoveryProgress;
68use fedimint_client_module::module::{
69    ClientContext, ClientModule, IClientModule, OutPointRange, PrimaryModulePriority,
70    PrimaryModuleSupport,
71};
72use fedimint_client_module::oplog::{OperationLogEntry, UpdateStreamOrOutcome};
73use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
74use fedimint_client_module::transaction::{
75    ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
76    ClientOutputSM, FeeQuote, FeeQuoteRequest, TransactionBuilder,
77};
78use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
79use fedimint_core::base32::{FEDIMINT_PREFIX, encode_prefixed};
80use fedimint_core::config::{FederationId, FederationIdPrefix};
81use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
82use fedimint_core::db::{
83    AutocommitError, Database, DatabaseTransaction, DatabaseVersion,
84    IDatabaseTransactionOpsCoreTyped,
85};
86use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
87use fedimint_core::invite_code::InviteCode;
88use fedimint_core::module::registry::{ModuleDecoderRegistry, ModuleRegistry};
89use fedimint_core::module::{
90    AmountUnit, Amounts, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
91};
92use fedimint_core::secp256k1::rand::prelude::IteratorRandom;
93use fedimint_core::secp256k1::rand::thread_rng;
94use fedimint_core::secp256k1::{All, Keypair, Secp256k1};
95use fedimint_core::util::{BoxFuture, BoxStream, FmtCompact as _, NextOrPending, SafeUrl};
96use fedimint_core::{
97    Amount, IdxRange, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId, apply,
98    async_trait_maybe_send, base32, push_db_pair_items, runtime,
99};
100use fedimint_derive_secret::{ChildId, DerivableSecret};
101use fedimint_logging::LOG_CLIENT_MODULE_MINT;
102pub use fedimint_mint_common as common;
103use fedimint_mint_common::config::{FeeConsensus, MintClientConfig};
104pub use fedimint_mint_common::*;
105use futures::future::try_join_all;
106use futures::{StreamExt, pin_mut};
107use hex::ToHex;
108use input::MintInputStateCreatedBundle;
109use itertools::Itertools as _;
110use output::MintOutputStatesCreatedMulti;
111use serde::{Deserialize, Serialize};
112use strum::IntoEnumIterator;
113use tbs::AggregatePublicKey;
114use tracing::{debug, warn};
115
116use crate::backup::EcashBackup;
117use crate::client_db::{
118    CancelledOOBSpendKey, CancelledOOBSpendKeyPrefix, NextECashNoteIndexKey,
119    NextECashNoteIndexKeyPrefix, NoteKey,
120};
121pub use crate::error::{
122    AwaitOutputFinalizedError, FetchRecoverySliceError, OOBNotesParseError,
123    PrepareEcashBackupError, RepairWalletError, SelectNotesError, SendOOBNotesError, SpendOOBError,
124    SubscribeReissueExternalNotesError, SubscribeSpendNotesError, ValidateNotesError,
125    VerifyBlindShareError,
126};
127use crate::input::{MintInputCommon, MintInputStateMachine, MintInputStates};
128use crate::oob::{MintOOBStateMachine, MintOOBStates, MintOOBStatesCreatedMulti};
129use crate::output::{
130    MintOutputCommon, MintOutputStateMachine, MintOutputStates, NoteIssuanceRequest,
131};
132
133const MINT_E_CASH_TYPE_CHILD_ID: ChildId = ChildId(0);
134
135const OOB_SPEND_NO_TIMEOUT: Duration = Duration::MAX;
136
137#[derive(Clone)]
138struct PeerSelector {
139    latency: Arc<RwLock<BTreeMap<PeerId, Duration>>>,
140}
141
142impl PeerSelector {
143    fn new(peers: BTreeSet<PeerId>) -> Self {
144        let latency = peers
145            .into_iter()
146            .map(|peer| (peer, Duration::ZERO))
147            .collect();
148
149        Self {
150            latency: Arc::new(RwLock::new(latency)),
151        }
152    }
153
154    fn choose_peer(&self) -> PeerId {
155        let latency = self.latency.read().expect("poisoned");
156
157        let peer_a = latency.iter().choose(&mut thread_rng()).expect("no peers");
158        let peer_b = latency.iter().choose(&mut thread_rng()).expect("no peers");
159
160        if peer_a.1 <= peer_b.1 {
161            *peer_a.0
162        } else {
163            *peer_b.0
164        }
165    }
166
167    fn report(&self, peer: PeerId, duration: Duration) {
168        self.latency
169            .write()
170            .expect("poisoned")
171            .entry(peer)
172            .and_modify(|latency| *latency = *latency * 9 / 10 + duration / 10)
173            .or_insert(duration);
174    }
175
176    fn remove(&self, peer: PeerId) {
177        self.latency.write().expect("poisoned").remove(&peer);
178    }
179}
180
181/// Downloads a slice with a pre-fetched hash for verification
182async fn download_slice_with_hash(
183    module_api: DynModuleApi,
184    peer_selector: PeerSelector,
185    start: u64,
186    end: u64,
187    expected_hash: sha256::Hash,
188) -> Vec<RecoveryItem> {
189    const TIMEOUT: Duration = Duration::from_secs(30);
190
191    loop {
192        let peer = peer_selector.choose_peer();
193        let start_time = fedimint_core::time::now();
194
195        match runtime::timeout(TIMEOUT, module_api.fetch_recovery_slice(peer, start, end)).await {
196            Ok(Ok(data)) => {
197                let elapsed = fedimint_core::time::now()
198                    .duration_since(start_time)
199                    .unwrap_or(Duration::ZERO);
200
201                peer_selector.report(peer, elapsed);
202
203                if data.consensus_hash::<sha256::Hash>() == expected_hash {
204                    return data;
205                }
206
207                peer_selector.remove(peer);
208            }
209            Ok(Err(..)) | Err(..) => {
210                peer_selector.report(peer, TIMEOUT);
211            }
212        }
213    }
214}
215
216/// An encapsulation of [`FederationId`] and e-cash notes in the form of
217/// [`TieredMulti<SpendableNote>`] for the purpose of spending e-cash
218/// out-of-band. Also used for validating and reissuing such out-of-band notes.
219///
220/// ## Invariants
221/// * Has to contain at least one `Notes` item
222/// * Has to contain at least one `FederationIdPrefix` item
223#[derive(Clone, Debug, Encodable, PartialEq, Eq)]
224pub struct OOBNotes(Vec<OOBNotesPart>);
225
226#[cfg(feature = "uniffi")]
227uniffi::custom_type!(OOBNotes, String, {
228    lower: |n| n.to_string(),
229    try_lift: |s| Ok(OOBNotes::from_str(&s)?),
230});
231
232/// For extendability [`OOBNotes`] consists of parts, where client can ignore
233/// ones they don't understand.
234#[derive(Clone, Debug, Decodable, Encodable, PartialEq, Eq)]
235enum OOBNotesPart {
236    Notes(TieredMulti<SpendableNote>),
237    FederationIdPrefix(FederationIdPrefix),
238    /// Invite code to join the federation by which the e-cash was issued
239    ///
240    /// Introduced in 0.3.0
241    Invite {
242        // This is a vec for future-proofness, in case we want to include multiple guardian APIs
243        peer_apis: Vec<(PeerId, SafeUrl)>,
244        federation_id: FederationId,
245    },
246    ApiSecret(String),
247    #[encodable_default]
248    Default {
249        variant: u64,
250        bytes: Vec<u8>,
251    },
252}
253
254impl OOBNotes {
255    pub fn new(
256        federation_id_prefix: FederationIdPrefix,
257        notes: TieredMulti<SpendableNote>,
258    ) -> Self {
259        Self(vec![
260            OOBNotesPart::FederationIdPrefix(federation_id_prefix),
261            OOBNotesPart::Notes(notes),
262        ])
263    }
264
265    pub fn new_with_invite(notes: TieredMulti<SpendableNote>, invite: &InviteCode) -> Self {
266        let mut data = vec![
267            // FIXME: once we can break compatibility with 0.2 we can remove the prefix in case an
268            // invite is present
269            OOBNotesPart::FederationIdPrefix(invite.federation_id().to_prefix()),
270            OOBNotesPart::Notes(notes),
271            OOBNotesPart::Invite {
272                peer_apis: vec![(invite.peer(), invite.url())],
273                federation_id: invite.federation_id(),
274            },
275        ];
276        if let Some(api_secret) = invite.api_secret() {
277            data.push(OOBNotesPart::ApiSecret(api_secret));
278        }
279        Self(data)
280    }
281
282    pub fn federation_id_prefix(&self) -> FederationIdPrefix {
283        self.0
284            .iter()
285            .find_map(|data| match data {
286                OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
287                OOBNotesPart::Invite { federation_id, .. } => Some(federation_id.to_prefix()),
288                _ => None,
289            })
290            .expect("Invariant violated: OOBNotes does not contain a FederationIdPrefix")
291    }
292
293    pub fn notes(&self) -> &TieredMulti<SpendableNote> {
294        self.0
295            .iter()
296            .find_map(|data| match data {
297                OOBNotesPart::Notes(notes) => Some(notes),
298                _ => None,
299            })
300            .expect("Invariant violated: OOBNotes does not contain any notes")
301    }
302
303    pub fn notes_json(&self) -> Result<serde_json::Value, serde_json::Error> {
304        let mut notes_map = serde_json::Map::new();
305        for notes in &self.0 {
306            match notes {
307                OOBNotesPart::Notes(notes) => {
308                    let notes_json: serde_json::Map<String, serde_json::Value> = notes
309                        .iter()
310                        .map(|(amount, notes_vec)| {
311                            let notes_with_nonce: Vec<serde_json::Value> = notes_vec
312                                .iter()
313                                .map(|note| {
314                                    serde_json::json!({
315                                        "signature": note.signature,
316                                        "spend_key": note.spend_key,
317                                        "nonce": note.nonce(),
318                                    })
319                                })
320                                .collect();
321                            (
322                                amount.msats.to_string(),
323                                serde_json::Value::Array(notes_with_nonce),
324                            )
325                        })
326                        .collect();
327                    notes_map.insert("notes".to_string(), serde_json::Value::Object(notes_json));
328                }
329                OOBNotesPart::FederationIdPrefix(prefix) => {
330                    notes_map.insert(
331                        "federation_id_prefix".to_string(),
332                        serde_json::to_value(prefix.to_string())?,
333                    );
334                }
335                OOBNotesPart::Invite {
336                    peer_apis,
337                    federation_id,
338                } => {
339                    let (peer_id, api) = peer_apis
340                        .first()
341                        .cloned()
342                        .expect("Decoding makes sure peer_apis isn't empty");
343                    notes_map.insert(
344                        "invite".to_string(),
345                        serde_json::to_value(InviteCode::new(
346                            api,
347                            peer_id,
348                            *federation_id,
349                            self.api_secret(),
350                        ))?,
351                    );
352                }
353                OOBNotesPart::ApiSecret(_) => { /* already covered inside `Invite` */ }
354                OOBNotesPart::Default { variant, bytes } => {
355                    notes_map.insert(
356                        format!("default_{variant}"),
357                        serde_json::to_value(bytes.encode_hex::<String>())?,
358                    );
359                }
360            }
361        }
362        Ok(serde_json::Value::Object(notes_map))
363    }
364
365    pub fn federation_invite(&self) -> Option<InviteCode> {
366        self.0.iter().find_map(|data| {
367            let OOBNotesPart::Invite {
368                peer_apis,
369                federation_id,
370            } = data
371            else {
372                return None;
373            };
374            let (peer_id, api) = peer_apis
375                .first()
376                .cloned()
377                .expect("Decoding makes sure peer_apis isn't empty");
378            Some(InviteCode::new(
379                api,
380                peer_id,
381                *federation_id,
382                self.api_secret(),
383            ))
384        })
385    }
386
387    fn api_secret(&self) -> Option<String> {
388        self.0.iter().find_map(|data| {
389            let OOBNotesPart::ApiSecret(api_secret) = data else {
390                return None;
391            };
392            Some(api_secret.clone())
393        })
394    }
395}
396
397impl Decodable for OOBNotes {
398    fn consensus_decode_partial<R: Read>(
399        r: &mut R,
400        _modules: &ModuleDecoderRegistry,
401    ) -> Result<Self, DecodeError> {
402        let inner =
403            Vec::<OOBNotesPart>::consensus_decode_partial(r, &ModuleDecoderRegistry::default())?;
404
405        // TODO: maybe write some macros for defining TLV structs?
406        if !inner
407            .iter()
408            .any(|data| matches!(data, OOBNotesPart::Notes(_)))
409        {
410            return Err(DecodeError::from_str(
411                "No e-cash notes were found in OOBNotes data",
412            ));
413        }
414
415        let maybe_federation_id_prefix = inner.iter().find_map(|data| match data {
416            OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
417            _ => None,
418        });
419
420        let maybe_invite = inner.iter().find_map(|data| match data {
421            OOBNotesPart::Invite {
422                federation_id,
423                peer_apis,
424            } => Some((federation_id, peer_apis)),
425            _ => None,
426        });
427
428        match (maybe_federation_id_prefix, maybe_invite) {
429            (Some(p), Some((ip, _))) => {
430                if p != ip.to_prefix() {
431                    return Err(DecodeError::from_str(
432                        "Inconsistent Federation ID provided in OOBNotes data",
433                    ));
434                }
435            }
436            (None, None) => {
437                return Err(DecodeError::from_str(
438                    "No Federation ID provided in OOBNotes data",
439                ));
440            }
441            _ => {}
442        }
443
444        if let Some((_, invite)) = maybe_invite
445            && invite.is_empty()
446        {
447            return Err(DecodeError::from_str("Invite didn't contain API endpoints"));
448        }
449
450        Ok(OOBNotes(inner))
451    }
452}
453
454const BASE64_URL_SAFE: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new(
455    &base64::alphabet::URL_SAFE,
456    base64::engine::general_purpose::PAD,
457);
458
459impl FromStr for OOBNotes {
460    type Err = OOBNotesParseError;
461
462    /// Decode a set of out-of-band e-cash notes from a base64 or base32 string.
463    fn from_str(s: &str) -> Result<Self, Self::Err> {
464        let s: String = s.chars().filter(|&c| !c.is_whitespace()).collect();
465
466        let oob_notes_bytes = if let Ok(oob_notes_bytes) =
467            base32::decode_prefixed_bytes(FEDIMINT_PREFIX, &s)
468        {
469            oob_notes_bytes
470        } else if let Ok(oob_notes_bytes) = BASE64_URL_SAFE.decode(&s) {
471            oob_notes_bytes
472        } else if let Ok(oob_notes_bytes) = base64::engine::general_purpose::STANDARD.decode(&s) {
473            oob_notes_bytes
474        } else {
475            return Err(OOBNotesParseError::Encoding);
476        };
477
478        let oob_notes =
479            OOBNotes::consensus_decode_whole(&oob_notes_bytes, &ModuleDecoderRegistry::default())?;
480
481        if oob_notes.notes().is_empty() {
482            return Err(OOBNotesParseError::Empty);
483        }
484
485        Ok(oob_notes)
486    }
487}
488
489impl Display for OOBNotes {
490    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
491        let bytes = Encodable::consensus_encode_to_vec(self);
492
493        f.write_str(&BASE64_URL_SAFE.encode(&bytes))
494    }
495}
496
497impl Serialize for OOBNotes {
498    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
499    where
500        S: serde::Serializer,
501    {
502        serializer.serialize_str(&self.to_string())
503    }
504}
505
506impl<'de> Deserialize<'de> for OOBNotes {
507    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
508    where
509        D: serde::Deserializer<'de>,
510    {
511        let s = String::deserialize(deserializer)?;
512        FromStr::from_str(&s).map_err(serde::de::Error::custom)
513    }
514}
515
516impl OOBNotes {
517    /// Returns the total value of all notes in msat as `Amount`
518    pub fn total_amount(&self) -> Amount {
519        self.notes().total_amount()
520    }
521}
522
523/// The high-level state of a reissue operation started with
524/// [`MintClientModule::reissue_external_notes`].
525#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
526#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
527pub enum ReissueExternalNotesState {
528    /// The operation has been created and is waiting to be accepted by the
529    /// federation.
530    Created,
531    /// We are waiting for blind signatures to arrive but can already assume the
532    /// transaction to be successful.
533    Issuing,
534    /// The operation has been completed successfully.
535    Done,
536    /// Some error happened and the operation failed.
537    Failed(String),
538}
539
540/// The result of [`MintClientModule::subscribe_reissue_external_notes`].
541pub type SubscribeReissueExternalNotesResult =
542    Result<UpdateStreamOrOutcome<ReissueExternalNotesState>, SubscribeReissueExternalNotesError>;
543
544/// The high-level state of a raw e-cash spend operation started with
545/// [`MintClientModule::spend_notes_with_selector`].
546#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
547#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
548pub enum SpendOOBState {
549    /// The e-cash has been selected and given to the caller
550    Created,
551    /// The user requested a cancellation of the operation, we are waiting for
552    /// the outcome of the cancel transaction.
553    UserCanceledProcessing,
554    /// The user-requested cancellation was successful, we got all our money
555    /// back.
556    UserCanceledSuccess,
557    /// The user-requested cancellation failed, the e-cash notes have been spent
558    /// by someone else already.
559    UserCanceledFailure,
560    /// We tried to cancel the operation automatically after the timeout but
561    /// failed, indicating the recipient reissued the e-cash to themselves,
562    /// making the out-of-band spend **successful**.
563    Success,
564    /// We tried to cancel the operation automatically after the timeout and
565    /// succeeded, indicating the recipient did not reissue the e-cash to
566    /// themselves, meaning the out-of-band spend **failed**.
567    Refunded,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct MintOperationMeta {
572    pub variant: MintOperationMetaVariant,
573    pub amount: Amount,
574    pub extra_meta: serde_json::Value,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
578#[serde(rename_all = "snake_case")]
579pub enum MintOperationMetaVariant {
580    // TODO: add migrations for operation log and clean up schema
581    /// Either `legacy_out_point` or both `txid` and `out_point_indices` will be
582    /// present.
583    Reissuance {
584        // Removed in 0.3.0:
585        #[serde(skip_serializing, default, rename = "out_point")]
586        legacy_out_point: Option<OutPoint>,
587        // Introduced in 0.3.0:
588        #[serde(default)]
589        txid: Option<TransactionId>,
590        // Introduced in 0.3.0:
591        #[serde(default)]
592        out_point_indices: Vec<u64>,
593    },
594    SpendOOB {
595        requested_amount: Amount,
596        oob_notes: OOBNotes,
597        #[serde(default)]
598        no_timeout: bool,
599    },
600}
601
602#[derive(Debug, Clone)]
603pub struct MintClientInit;
604
605const SLICE_SIZE: u64 = 10000;
606const PARALLEL_HASH_REQUESTS: usize = 10;
607const PARALLEL_SLICE_REQUESTS: usize = 10;
608
609impl MintClientInit {
610    #[allow(clippy::too_many_lines)]
611    async fn recover_from_slices(
612        &self,
613        args: &ClientModuleRecoverArgs<Self>,
614    ) -> anyhow::Result<Option<Amount>> {
615        // Try to load existing state or create new one if we can fetch recovery count
616        let mut state = if let Some(state) = args
617            .db()
618            .begin_transaction_nc()
619            .await
620            .get_value(&RecoveryStateV2Key)
621            .await
622        {
623            state
624        } else {
625            // Try to fetch recovery count - if this fails, the endpoint doesn't exist
626            let total_items = args.module_api().fetch_recovery_count().await?;
627
628            RecoveryStateV2::new(
629                total_items,
630                args.cfg().tbs_pks.tiers().copied().collect(),
631                args.module_root_secret(),
632            )
633        };
634
635        if state.next_index == state.total_items {
636            return Ok(None);
637        }
638
639        let peer_selector = PeerSelector::new(args.api().all_peers().clone());
640
641        let mut recovery_stream = futures::stream::iter(
642            (state.next_index..state.total_items).step_by(SLICE_SIZE as usize),
643        )
644        .map(move |start| {
645            let api = args.module_api().clone();
646            let end = std::cmp::min(start + SLICE_SIZE, state.total_items);
647
648            async move { (start, end, api.fetch_recovery_slice_hash(start, end).await) }
649        })
650        .buffered(PARALLEL_HASH_REQUESTS)
651        .map(move |(start, end, hash)| {
652            download_slice_with_hash(
653                args.module_api().clone(),
654                peer_selector.clone(),
655                start,
656                end,
657                hash,
658            )
659        })
660        .buffered(PARALLEL_SLICE_REQUESTS);
661
662        let secret = args.module_root_secret().clone();
663
664        loop {
665            let items = recovery_stream
666                .next()
667                .await
668                .expect("mint recovery stream finished before recovery is complete");
669
670            for item in &items {
671                match item {
672                    RecoveryItem::Output { amount, nonce } => {
673                        state.handle_output(*amount, *nonce, &secret);
674                    }
675                    RecoveryItem::Input { nonce } => {
676                        state.handle_input(*nonce);
677                    }
678                }
679            }
680
681            state.next_index += items.len() as u64;
682
683            let mut dbtx = args.db().begin_transaction().await;
684
685            dbtx.insert_entry(&RecoveryStateV2Key, &state).await;
686
687            if state.next_index == state.total_items {
688                // Finalize recovery - create state machines for pending outputs
689                let finalized = state.finalize();
690
691                // Total value of the notes reconstructed during recovery
692                let recovered_amount = finalized
693                    .pending_notes
694                    .iter()
695                    .map(|(amount, _)| *amount)
696                    .sum::<Amount>();
697
698                // Collect blind nonces to fetch outpoints from server
699                let blind_nonces: Vec<BlindNonce> = finalized
700                    .pending_notes
701                    .iter()
702                    .map(|(_, req)| BlindNonce(req.blinded_message()))
703                    .collect();
704
705                // Fetch outpoints for all blind nonces
706                let outpoints = if blind_nonces.is_empty() {
707                    vec![]
708                } else {
709                    args.module_api()
710                        .fetch_blind_nonce_outpoints(blind_nonces)
711                        .await
712                        .context("Failed to fetch blind nonce outpoints")?
713                };
714
715                // Create state machines for pending notes
716                let state_machines: Vec<MintClientStateMachines> = finalized
717                    .pending_notes
718                    .into_iter()
719                    .zip(outpoints)
720                    .map(|((amount, issuance_request), out_point)| {
721                        MintClientStateMachines::Output(MintOutputStateMachine {
722                            common: MintOutputCommon {
723                                operation_id: OperationId::new_random(),
724                                out_point_range: OutPointRange::new_single(
725                                    out_point.txid,
726                                    out_point.out_idx,
727                                )
728                                .expect("Can't overflow"),
729                            },
730                            state: MintOutputStates::Created(output::MintOutputStatesCreated {
731                                amount,
732                                issuance_request,
733                            }),
734                        })
735                    })
736                    .collect();
737
738                let state_machines = args.context().map_dyn(state_machines).collect();
739
740                args.context()
741                    .add_state_machines_dbtx(&mut dbtx.to_ref_nc(), state_machines)
742                    .await?;
743
744                // Restore NextECashNoteIndexKey
745                for (amount, note_idx) in finalized.next_note_idx {
746                    dbtx.insert_entry(&NextECashNoteIndexKey(amount), &note_idx.as_u64())
747                        .await;
748                }
749
750                dbtx.commit_tx().await;
751
752                return Ok(Some(recovered_amount));
753            }
754
755            dbtx.commit_tx().await;
756
757            args.update_recovery_progress(RecoveryProgress {
758                complete: state.next_index.try_into().unwrap_or(u32::MAX),
759                total: state.total_items.try_into().unwrap_or(u32::MAX),
760            });
761        }
762    }
763}
764
765impl ModuleInit for MintClientInit {
766    type Common = MintCommonInit;
767
768    async fn dump_database(
769        &self,
770        dbtx: &mut DatabaseTransaction<'_>,
771        prefix_names: Vec<String>,
772    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
773        let mut mint_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
774            BTreeMap::new();
775        let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
776            prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
777        });
778
779        for table in filtered_prefixes {
780            match table {
781                DbKeyPrefix::Note => {
782                    push_db_pair_items!(
783                        dbtx,
784                        NoteKeyPrefix,
785                        NoteKey,
786                        SpendableNoteUndecoded,
787                        mint_client_items,
788                        "Notes"
789                    );
790                }
791                DbKeyPrefix::NextECashNoteIndex => {
792                    push_db_pair_items!(
793                        dbtx,
794                        NextECashNoteIndexKeyPrefix,
795                        NextECashNoteIndexKey,
796                        u64,
797                        mint_client_items,
798                        "NextECashNoteIndex"
799                    );
800                }
801                DbKeyPrefix::CancelledOOBSpend => {
802                    push_db_pair_items!(
803                        dbtx,
804                        CancelledOOBSpendKeyPrefix,
805                        CancelledOOBSpendKey,
806                        (),
807                        mint_client_items,
808                        "CancelledOOBSpendKey"
809                    );
810                }
811                DbKeyPrefix::RecoveryFinalized => {
812                    if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await {
813                        mint_client_items.insert("RecoveryFinalized".to_string(), Box::new(val));
814                    }
815                }
816                DbKeyPrefix::RecoveryState
817                | DbKeyPrefix::ReusedNoteIndices
818                | DbKeyPrefix::RecoveryStateV2
819                | DbKeyPrefix::ExternalReservedStart
820                | DbKeyPrefix::CoreInternalReservedStart
821                | DbKeyPrefix::CoreInternalReservedEnd => {}
822            }
823        }
824
825        Box::new(mint_client_items.into_iter())
826    }
827}
828
829#[apply(async_trait_maybe_send!)]
830impl ClientModuleInit for MintClientInit {
831    type Module = MintClientModule;
832
833    fn supported_api_versions(&self) -> MultiApiVersion {
834        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
835            .expect("no version conflicts")
836    }
837
838    async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
839        Ok(MintClientModule {
840            federation_id: *args.federation_id(),
841            cfg: args.cfg().clone(),
842            secret: args.module_root_secret().clone(),
843            secp: Secp256k1::new(),
844            notifier: args.notifier().clone(),
845            client_ctx: args.context(),
846            balance_update_sender: tokio::sync::watch::channel(()).0,
847        })
848    }
849
850    fn recovery_mode(&self) -> RecoveryMode {
851        RecoveryMode::Unusable
852    }
853
854    async fn recover(
855        &self,
856        args: &ClientModuleRecoverArgs<Self>,
857        snapshot: Option<&<Self::Module as ClientModule>::Backup>,
858    ) -> anyhow::Result<Option<Amount>> {
859        let mut dbtx = args.db().begin_transaction_nc().await;
860
861        // Check if V2 (slice-based) recovery state exists
862        if dbtx.get_value(&RecoveryStateV2Key).await.is_some() {
863            return self.recover_from_slices(args).await;
864        }
865
866        // Check if V1 (session-based) recovery state exists
867        if dbtx.get_value(&RecoveryStateKey).await.is_some() {
868            return args
869                .recover_from_history::<MintRecovery>(self, snapshot)
870                .await;
871        }
872
873        // No existing recovery state - determine which to use based on endpoint
874        // availability
875        if args.module_api().fetch_recovery_count().await.is_ok() {
876            // New endpoint available - use V2 slice-based recovery
877            self.recover_from_slices(args).await
878        } else {
879            // Old federation - use V1 session-based recovery
880            args.recover_from_history::<MintRecovery>(self, snapshot)
881                .await
882        }
883    }
884
885    fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
886        let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = BTreeMap::new();
887        migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
888            Box::pin(migrate_to_v1(dbtx))
889        });
890        migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
891            Box::pin(async { migrate_state(active_states, inactive_states, migrate_state_to_v2) })
892        });
893
894        migrations
895    }
896
897    fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
898        Some(
899            DbKeyPrefix::iter()
900                .map(|p| p as u8)
901                .chain(
902                    DbKeyPrefix::ExternalReservedStart as u8
903                        ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
904                )
905                .collect(),
906        )
907    }
908}
909
910/// The `MintClientModule` is responsible for handling e-cash minting
911/// operations. It interacts with the mint server to issue, reissue, and
912/// validate e-cash notes.
913///
914/// # Derivable Secret
915///
916/// The `DerivableSecret` is a cryptographic secret that can be used to derive
917/// other secrets. In the context of the `MintClientModule`, it is used to
918/// derive the blinding and spend keys for e-cash notes. The `DerivableSecret`
919/// is initialized when the `MintClientModule` is created and is kept private
920/// within the module.
921///
922/// # Blinding Key
923///
924/// The blinding key is derived from the `DerivableSecret` and is used to blind
925/// the e-cash note during the issuance process. This ensures that the mint
926/// server cannot link the e-cash note to the client that requested it,
927/// providing privacy for the client.
928///
929/// # Spend Key
930///
931/// The spend key is also derived from the `DerivableSecret` and is used to
932/// spend the e-cash note. Only the client that possesses the `DerivableSecret`
933/// can derive the correct spend key to spend the e-cash note. This ensures that
934/// only the owner of the e-cash note can spend it.
935#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
936pub struct MintClientModule {
937    federation_id: FederationId,
938    cfg: MintClientConfig,
939    secret: DerivableSecret,
940    secp: Secp256k1<All>,
941    notifier: ModuleNotifier<MintClientStateMachines>,
942    pub client_ctx: ClientContext<Self>,
943    balance_update_sender: tokio::sync::watch::Sender<()>,
944}
945
946impl fmt::Debug for MintClientModule {
947    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
948        f.debug_struct("MintClientModule")
949            .field("federation_id", &self.federation_id)
950            .field("cfg", &self.cfg)
951            .field("notifier", &self.notifier)
952            .field("client_ctx", &self.client_ctx)
953            .finish_non_exhaustive()
954    }
955}
956
957// TODO: wrap in Arc
958#[derive(Clone)]
959pub struct MintClientContext {
960    pub federation_id: FederationId,
961    pub client_ctx: ClientContext<MintClientModule>,
962    pub mint_decoder: Decoder,
963    pub tbs_pks: Tiered<AggregatePublicKey>,
964    pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>,
965    pub secret: DerivableSecret,
966    // FIXME: putting a DB ref here is an antipattern, global context should become more powerful
967    // but we need to consider it more carefully as its APIs will be harder to change.
968    pub module_db: Database,
969    /// Notifies subscribers when the balance changes
970    pub balance_update_sender: tokio::sync::watch::Sender<()>,
971}
972
973impl fmt::Debug for MintClientContext {
974    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
975        f.debug_struct("MintClientContext")
976            .field("federation_id", &self.federation_id)
977            .finish_non_exhaustive()
978    }
979}
980
981impl MintClientContext {
982    fn await_cancel_oob_payment(&self, operation_id: OperationId) -> BoxFuture<'static, ()> {
983        let db = self.module_db.clone();
984        Box::pin(async move {
985            db.wait_key_exists(&CancelledOOBSpendKey(operation_id))
986                .await;
987        })
988    }
989}
990
991impl Context for MintClientContext {
992    const KIND: Option<ModuleKind> = Some(KIND);
993}
994
995#[apply(async_trait_maybe_send!)]
996impl ClientModule for MintClientModule {
997    type Init = MintClientInit;
998    type Common = MintModuleTypes;
999    type Backup = EcashBackup;
1000    type ModuleStateMachineContext = MintClientContext;
1001    type States = MintClientStateMachines;
1002
1003    fn context(&self) -> Self::ModuleStateMachineContext {
1004        MintClientContext {
1005            federation_id: self.federation_id,
1006            client_ctx: self.client_ctx.clone(),
1007            mint_decoder: self.decoder(),
1008            tbs_pks: self.cfg.tbs_pks.clone(),
1009            peer_tbs_pks: self.cfg.peer_tbs_pks.clone(),
1010            secret: self.secret.clone(),
1011            module_db: self.client_ctx.module_db().clone(),
1012            balance_update_sender: self.balance_update_sender.clone(),
1013        }
1014    }
1015
1016    fn input_fee(
1017        &self,
1018        amount: &Amounts,
1019        _input: &<Self::Common as ModuleCommon>::Input,
1020    ) -> Option<Amounts> {
1021        Some(Amounts::new_bitcoin(
1022            self.cfg.fee_consensus.fee(amount.get_bitcoin()),
1023        ))
1024    }
1025
1026    fn output_fee(
1027        &self,
1028        amount: &Amounts,
1029        _output: &<Self::Common as ModuleCommon>::Output,
1030    ) -> Option<Amounts> {
1031        Some(Amounts::new_bitcoin(
1032            self.cfg.fee_consensus.fee(amount.get_bitcoin()),
1033        ))
1034    }
1035
1036    #[cfg(feature = "cli")]
1037    async fn handle_cli_command(
1038        &self,
1039        args: &[std::ffi::OsString],
1040    ) -> anyhow::Result<serde_json::Value> {
1041        cli::handle_cli_command(self, args).await
1042    }
1043
1044    fn supports_backup(&self) -> bool {
1045        true
1046    }
1047
1048    async fn backup(&self) -> anyhow::Result<EcashBackup> {
1049        self.client_ctx
1050            .module_db()
1051            .autocommit(
1052                |dbtx_ctx, _| {
1053                    Box::pin(async { self.prepare_plaintext_ecash_backup(dbtx_ctx).await })
1054                },
1055                None,
1056            )
1057            .await
1058            .map_err(|e| match e {
1059                AutocommitError::ClosureError { error, .. } => anyhow::Error::from(error),
1060                AutocommitError::CommitFailed { last_error, .. } => {
1061                    anyhow!("Commit to DB failed: {last_error}")
1062                }
1063            })
1064    }
1065
1066    fn supports_being_primary(&self) -> PrimaryModuleSupport {
1067        PrimaryModuleSupport::selected(PrimaryModulePriority::HIGH, [AmountUnit::BITCOIN])
1068    }
1069
1070    async fn create_final_inputs_and_outputs(
1071        &self,
1072        dbtx: &mut DatabaseTransaction<'_>,
1073        operation_id: OperationId,
1074        unit: AmountUnit,
1075        mut input_amount: Amount,
1076        mut output_amount: Amount,
1077    ) -> anyhow::Result<(
1078        ClientInputBundle<MintInput, MintClientStateMachines>,
1079        ClientOutputBundle<MintOutput, MintClientStateMachines>,
1080    )> {
1081        let consolidation_inputs = self.consolidate_notes(dbtx).await?;
1082
1083        if unit != AmountUnit::BITCOIN {
1084            bail!("Module can only handle Bitcoin");
1085        }
1086
1087        input_amount += consolidation_inputs
1088            .iter()
1089            .map(|input| input.0.amounts.get_bitcoin())
1090            .sum();
1091
1092        output_amount += consolidation_inputs
1093            .iter()
1094            .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
1095            .sum();
1096
1097        let additional_inputs = self
1098            .create_sufficient_input(dbtx, output_amount.saturating_sub(input_amount))
1099            .await?;
1100
1101        input_amount += additional_inputs
1102            .iter()
1103            .map(|input| input.0.amounts.get_bitcoin())
1104            .sum();
1105
1106        output_amount += additional_inputs
1107            .iter()
1108            .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
1109            .sum();
1110
1111        let outputs = self
1112            .create_output(
1113                dbtx,
1114                operation_id,
1115                2,
1116                input_amount.saturating_sub(output_amount),
1117            )
1118            .await;
1119
1120        Ok((
1121            create_bundle_for_inputs(
1122                [consolidation_inputs, additional_inputs].concat(),
1123                operation_id,
1124            ),
1125            outputs,
1126        ))
1127    }
1128
1129    async fn await_primary_module_output(
1130        &self,
1131        operation_id: OperationId,
1132        out_point: OutPoint,
1133    ) -> anyhow::Result<()> {
1134        self.await_output_finalized(operation_id, out_point).await?;
1135        Ok(())
1136    }
1137
1138    async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
1139        if unit != AmountUnit::BITCOIN {
1140            return Amount::ZERO;
1141        }
1142        self.get_note_counts_by_denomination(dbtx)
1143            .await
1144            .total_amount()
1145    }
1146
1147    async fn get_balances(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
1148        Amounts::new_bitcoin(
1149            <Self as ClientModule>::get_balance(self, dbtx, AmountUnit::BITCOIN).await,
1150        )
1151    }
1152
1153    async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
1154        Box::pin(tokio_stream::wrappers::WatchStream::new(
1155            self.balance_update_sender.subscribe(),
1156        ))
1157    }
1158
1159    async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
1160        let balance = ClientModule::get_balances(self, dbtx).await;
1161
1162        for (unit, amount) in balance {
1163            if Amount::from_units(0) < amount {
1164                bail!("Outstanding balance: {amount}, unit: {unit:?}");
1165            }
1166        }
1167
1168        if !self.client_ctx.get_own_active_states().await.is_empty() {
1169            bail!("Pending operations")
1170        }
1171        Ok(())
1172    }
1173
1174    async fn handle_rpc(
1175        &self,
1176        method: String,
1177        request: serde_json::Value,
1178    ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
1179        Box::pin(try_stream! {
1180            match method.as_str() {
1181                "reissue_external_notes" => {
1182                    let req: ReissueExternalNotesRequest = serde_json::from_value(request)?;
1183                    let result = self.reissue_external_notes(req.oob_notes, req.extra_meta).await?;
1184                    yield serde_json::to_value(result)?;
1185                }
1186                "subscribe_reissue_external_notes" => {
1187                    let req: SubscribeReissueExternalNotesRequest = serde_json::from_value(request)?;
1188                    let stream = self.subscribe_reissue_external_notes(req.operation_id).await?;
1189                    for await state in stream.into_stream() {
1190                        yield serde_json::to_value(state)?;
1191                    }
1192                }
1193                "spend_notes" => {
1194                    let req: SpendNotesRequest = serde_json::from_value(request)?;
1195                    let result = self.spend_notes_with_selector(
1196                        &SelectNotesWithExactAmount,
1197                        req.amount,
1198                        req.try_cancel_after,
1199                        req.include_invite,
1200                        req.extra_meta
1201                    ).await?;
1202                    yield serde_json::to_value(result)?;
1203                }
1204                "spend_notes_expert" => {
1205                    let req: SpendNotesExpertRequest = serde_json::from_value(request)?;
1206                    let result = self.spend_notes_with_selector(
1207                        &SelectNotesWithAtleastAmount,
1208                        req.min_amount,
1209                        req.try_cancel_after,
1210                        req.include_invite,
1211                        req.extra_meta
1212                    ).await?;
1213                    yield serde_json::to_value(result)?;
1214                }
1215                "validate_notes" => {
1216                    let req: ValidateNotesRequest = serde_json::from_value(request)?;
1217                    let result = self.validate_notes(&req.oob_notes)?;
1218                    yield serde_json::to_value(result)?;
1219                }
1220                "try_cancel_spend_notes" => {
1221                    let req: TryCancelSpendNotesRequest = serde_json::from_value(request)?;
1222                    let result = self.try_cancel_spend_notes(req.operation_id).await;
1223                    yield serde_json::to_value(result)?;
1224                }
1225                "subscribe_spend_notes" => {
1226                    let req: SubscribeSpendNotesRequest = serde_json::from_value(request)?;
1227                    let stream = self.subscribe_spend_notes(req.operation_id).await?;
1228                    for await state in stream.into_stream() {
1229                        yield serde_json::to_value(state)?;
1230                    }
1231                }
1232                "await_spend_oob_refund" => {
1233                    let req: AwaitSpendOobRefundRequest = serde_json::from_value(request)?;
1234                    let value = self.await_spend_oob_refund(req.operation_id).await;
1235                    yield serde_json::to_value(value)?;
1236                }
1237                "note_counts_by_denomination" => {
1238                    let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1239                    let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
1240                    yield serde_json::to_value(note_counts)?;
1241                }
1242                _ => {
1243                    Err(anyhow::format_err!("Unknown method: {method}"))?;
1244                    unreachable!()
1245                },
1246            }
1247        })
1248    }
1249}
1250
1251#[derive(Deserialize)]
1252struct ReissueExternalNotesRequest {
1253    oob_notes: OOBNotes,
1254    extra_meta: serde_json::Value,
1255}
1256
1257#[derive(Deserialize)]
1258struct SubscribeReissueExternalNotesRequest {
1259    operation_id: OperationId,
1260}
1261
1262/// Caution: if no notes of the correct denomination are available the next
1263/// bigger note will be selected. You might want to use `spend_notes` instead.
1264#[derive(Deserialize)]
1265struct SpendNotesExpertRequest {
1266    min_amount: Amount,
1267    try_cancel_after: Option<Duration>,
1268    include_invite: bool,
1269    extra_meta: serde_json::Value,
1270}
1271
1272#[derive(Deserialize)]
1273struct SpendNotesRequest {
1274    amount: Amount,
1275    try_cancel_after: Option<Duration>,
1276    include_invite: bool,
1277    extra_meta: serde_json::Value,
1278}
1279
1280#[derive(Deserialize)]
1281struct ValidateNotesRequest {
1282    oob_notes: OOBNotes,
1283}
1284
1285#[derive(Deserialize)]
1286struct TryCancelSpendNotesRequest {
1287    operation_id: OperationId,
1288}
1289
1290#[derive(Deserialize)]
1291struct SubscribeSpendNotesRequest {
1292    operation_id: OperationId,
1293}
1294
1295#[derive(Deserialize)]
1296struct AwaitSpendOobRefundRequest {
1297    operation_id: OperationId,
1298}
1299
1300/// A failure to reissue e-cash notes received from a third party.
1301#[derive(thiserror::Error, Debug)]
1302#[non_exhaustive]
1303pub enum ReissueExternalNotesError {
1304    /// The notes are worth nothing, so there is nothing to reissue.
1305    #[error("Reissuing zero-amount e-cash is not supported")]
1306    ZeroAmount,
1307
1308    /// The notes were issued by a different federation.
1309    #[error("The notes were issued by federation {found}, not {expected}")]
1310    WrongFederationId {
1311        /// The federation this client belongs to.
1312        expected: FederationIdPrefix,
1313        /// The federation the notes name.
1314        found: FederationIdPrefix,
1315    },
1316
1317    /// An operation for these exact notes already exists, so they were already
1318    /// handed to this federation.
1319    #[error("We already reissued these notes")]
1320    AlreadyReissued,
1321
1322    /// The notes cannot be spent.
1323    #[error("The notes could not be validated")]
1324    Notes(#[from] ValidateNotesError),
1325
1326    /// The reissue transaction could not be built or submitted.
1327    #[error("The reissue transaction could not be submitted")]
1328    Transaction(#[source] TransactionSubmitError),
1329}
1330
1331impl MintClientModule {
1332    async fn create_sufficient_input(
1333        &self,
1334        dbtx: &mut DatabaseTransaction<'_>,
1335        min_amount: Amount,
1336    ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1337        if min_amount == Amount::ZERO {
1338            return Ok(vec![]);
1339        }
1340
1341        let selected_notes = Self::select_notes(
1342            dbtx,
1343            &SelectNotesWithAtleastAmount,
1344            min_amount,
1345            self.cfg.fee_consensus.clone(),
1346        )
1347        .await?;
1348
1349        for (amount, note) in selected_notes.iter_items() {
1350            debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as sufficient input to fund a tx");
1351            MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1352        }
1353
1354        let sender = self.balance_update_sender.clone();
1355        dbtx.on_commit(move || sender.send_replace(()));
1356
1357        let inputs = self.create_input_from_notes(selected_notes)?;
1358
1359        assert!(!inputs.is_empty());
1360
1361        Ok(inputs)
1362    }
1363
1364    /// Returns the number of held e-cash notes per denomination
1365    #[deprecated(
1366        since = "0.5.0",
1367        note = "Use `get_note_counts_by_denomination` instead"
1368    )]
1369    pub async fn get_notes_tier_counts(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1370        self.get_note_counts_by_denomination(dbtx).await
1371    }
1372
1373    /// Pick [`SpendableNote`]s by given counts, when available
1374    ///
1375    /// Return the notes picked, and counts of notes that were not available.
1376    pub async fn get_available_notes_by_tier_counts(
1377        &self,
1378        dbtx: &mut DatabaseTransaction<'_>,
1379        counts: TieredCounts,
1380    ) -> (TieredMulti<SpendableNoteUndecoded>, TieredCounts) {
1381        dbtx.find_by_prefix(&NoteKeyPrefix)
1382            .await
1383            .fold(
1384                (TieredMulti::<SpendableNoteUndecoded>::default(), counts),
1385                |(mut notes, mut counts), (key, note)| async move {
1386                    let amount = key.amount;
1387                    if 0 < counts.get(amount) {
1388                        counts.dec(amount);
1389                        notes.push(amount, note);
1390                    }
1391
1392                    (notes, counts)
1393                },
1394            )
1395            .await
1396    }
1397
1398    // TODO: put "notes per denomination" default into cfg
1399    /// Creates a mint output close to the given `amount`, issuing e-cash
1400    /// notes such that the client holds `notes_per_denomination` notes of each
1401    /// e-cash note denomination held.
1402    pub async fn create_output(
1403        &self,
1404        dbtx: &mut DatabaseTransaction<'_>,
1405        operation_id: OperationId,
1406        notes_per_denomination: u16,
1407        exact_amount: Amount,
1408    ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1409        if exact_amount == Amount::ZERO {
1410            return ClientOutputBundle::new(vec![], vec![]);
1411        }
1412
1413        // Change layout: carve notes out of `exact_amount`, paying each note's
1414        // own fee from that same value (the leftover below a note's fee is dust).
1415        let denominations = represent_amount(
1416            exact_amount,
1417            &self.get_note_counts_by_denomination(dbtx).await,
1418            &self.cfg.tbs_pks,
1419            notes_per_denomination,
1420            &self.cfg.fee_consensus,
1421        );
1422
1423        self.create_output_for_denominations(dbtx, operation_id, denominations)
1424            .await
1425    }
1426
1427    /// Issues note outputs worth *exactly* `amount`, with no fee carved out of
1428    /// that value — the federation fee is funded separately by the primary
1429    /// module's balancing (extra inputs pulled in by
1430    /// `create_final_inputs_and_outputs`). This is how a *target* amount should
1431    /// be minted (e.g. an ecash send reissuing itself the denominations to hand
1432    /// out), as opposed to laying out change; it mirrors mintv2's `send`.
1433    ///
1434    /// Because the smallest denomination is 1 msat (denominations are
1435    /// contiguous powers of two), every amount is exactly representable, so
1436    /// a single reissue always yields notes that can be spent for the exact
1437    /// amount.
1438    async fn create_exact_output(
1439        &self,
1440        dbtx: &mut DatabaseTransaction<'_>,
1441        operation_id: OperationId,
1442        amount: Amount,
1443    ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1444        if amount == Amount::ZERO {
1445            return ClientOutputBundle::new(vec![], vec![]);
1446        }
1447
1448        self.create_output_for_denominations(
1449            dbtx,
1450            operation_id,
1451            self.represent_exact_amount(amount),
1452        )
1453        .await
1454    }
1455
1456    /// Decomposes `amount` into the minimal set of note denominations summing
1457    /// to *exactly* `amount` — a plain greedy power-of-two breakdown with
1458    /// no fee subtracted (unlike [`represent_amount`], which lays out
1459    /// change). Used when minting a target amount; see
1460    /// [`Self::create_exact_output`].
1461    fn represent_exact_amount(&self, amount: Amount) -> TieredCounts {
1462        represent_amount(
1463            amount,
1464            &TieredCounts::default(),
1465            &self.cfg.tbs_pks,
1466            0,
1467            &FeeConsensus::zero(),
1468        )
1469    }
1470
1471    async fn create_output_for_denominations(
1472        &self,
1473        dbtx: &mut DatabaseTransaction<'_>,
1474        operation_id: OperationId,
1475        denominations: TieredCounts,
1476    ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1477        let mut outputs = Vec::new();
1478        let mut issuance_requests = Vec::new();
1479
1480        for (amount, num) in denominations.iter() {
1481            for _ in 0..num {
1482                let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await;
1483
1484                debug!(
1485                    %amount,
1486                    "Generated issuance request"
1487                );
1488
1489                outputs.push(ClientOutput {
1490                    output: MintOutput::new_v0(amount, blind_nonce),
1491                    amounts: Amounts::new_bitcoin(amount),
1492                });
1493
1494                issuance_requests.push((amount, issuance_request));
1495            }
1496        }
1497
1498        let state_generator = Arc::new(move |out_point_range: OutPointRange| {
1499            assert_eq!(out_point_range.count(), issuance_requests.len());
1500            vec![MintClientStateMachines::Output(MintOutputStateMachine {
1501                common: MintOutputCommon {
1502                    operation_id,
1503                    out_point_range,
1504                },
1505                state: MintOutputStates::CreatedMulti(MintOutputStatesCreatedMulti {
1506                    issuance_requests: out_point_range
1507                        .into_iter()
1508                        .map(|out_point| out_point.out_idx)
1509                        .zip(issuance_requests.clone())
1510                        .collect(),
1511                }),
1512            })]
1513        });
1514
1515        ClientOutputBundle::new(
1516            outputs,
1517            vec![ClientOutputSM {
1518                state_machines: state_generator,
1519            }],
1520        )
1521    }
1522
1523    /// Returns the number of held e-cash notes per denomination
1524    pub async fn get_note_counts_by_denomination(
1525        &self,
1526        dbtx: &mut DatabaseTransaction<'_>,
1527    ) -> TieredCounts {
1528        dbtx.find_by_prefix(&NoteKeyPrefix)
1529            .await
1530            .fold(
1531                TieredCounts::default(),
1532                |mut acc, (key, _note)| async move {
1533                    acc.inc(key.amount, 1);
1534                    acc
1535                },
1536            )
1537            .await
1538    }
1539
1540    /// Returns the number of held e-cash notes per denomination
1541    #[deprecated(
1542        since = "0.5.0",
1543        note = "Use `get_note_counts_by_denomination` instead"
1544    )]
1545    pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1546        self.get_note_counts_by_denomination(dbtx).await
1547    }
1548
1549    /// Estimates the total fees to spend all currently held notes.
1550    ///
1551    /// This is useful for calculating max withdrawable amounts, where all
1552    /// notes will be spent. Notes that are uneconomical to spend (fee >= value)
1553    /// are excluded from the calculation since the wallet won't spend them.
1554    pub async fn estimate_spend_all_fees(&self) -> Amount {
1555        let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1556        let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
1557
1558        note_counts
1559            .iter()
1560            .filter_map(|(amount, count)| {
1561                let note_fee = self.cfg.fee_consensus.fee(amount);
1562                if note_fee < amount {
1563                    note_fee.checked_mul(count as u64)
1564                } else {
1565                    None
1566                }
1567            })
1568            .fold(Amount::ZERO, |acc, fee| {
1569                acc.checked_add(fee).expect("fee sum overflow")
1570            })
1571    }
1572
1573    /// Wait for the e-cash notes to be retrieved. If this is not possible
1574    /// because another terminal state was reached an error describing the
1575    /// failure is returned.
1576    pub async fn await_output_finalized(
1577        &self,
1578        operation_id: OperationId,
1579        out_point: OutPoint,
1580    ) -> Result<(), AwaitOutputFinalizedError> {
1581        let stream = self
1582            .notifier
1583            .subscribe(operation_id)
1584            .await
1585            .filter_map(|state| async {
1586                let MintClientStateMachines::Output(state) = state else {
1587                    return None;
1588                };
1589
1590                if state.common.txid() != out_point.txid
1591                    || !state
1592                        .common
1593                        .out_point_range
1594                        .out_idx_iter()
1595                        .contains(&out_point.out_idx)
1596                {
1597                    return None;
1598                }
1599
1600                match state.state {
1601                    MintOutputStates::Succeeded(_) => Some(Ok(())),
1602                    MintOutputStates::Aborted(_) => {
1603                        Some(Err(AwaitOutputFinalizedError::TransactionRejected))
1604                    }
1605                    MintOutputStates::Failed(failed) => {
1606                        Some(Err(AwaitOutputFinalizedError::Failed {
1607                            reason: failed.error,
1608                        }))
1609                    }
1610                    MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1611                }
1612            });
1613        pin_mut!(stream);
1614
1615        stream.next_or_pending().await
1616    }
1617
1618    /// Provisional implementation of note consolidation
1619    ///
1620    /// When a certain denomination crosses the threshold of notes allowed,
1621    /// spend some chunk of them as inputs.
1622    ///
1623    /// Return notes and the sume of their amount.
1624    pub async fn consolidate_notes(
1625        &self,
1626        dbtx: &mut DatabaseTransaction<'_>,
1627    ) -> Result<Vec<(ClientInput<MintInput>, SpendableNote)>, ValidateNotesError> {
1628        /// At how many notes of the same denomination should we try to
1629        /// consolidate
1630        const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1631        /// Number of notes per tier to leave after threshold was crossed
1632        const MIN_NOTES_PER_TIER: usize = 4;
1633        /// Maximum number of notes to consolidate per one tx,
1634        /// to limit the size of a transaction produced.
1635        const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1636        // it's fine, it's just documentation
1637        #[allow(clippy::assertions_on_constants)]
1638        {
1639            assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1640        }
1641
1642        let counts = self.get_note_counts_by_denomination(dbtx).await;
1643
1644        let should_consolidate = counts
1645            .iter()
1646            .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1647
1648        if !should_consolidate {
1649            return Ok(vec![]);
1650        }
1651
1652        let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1653
1654        let excessive_counts: TieredCounts = counts
1655            .iter()
1656            .map(|(amount, count)| {
1657                let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1658
1659                max_count -= take;
1660                (amount, take)
1661            })
1662            .collect();
1663
1664        let (selected_notes, unavailable) = self
1665            .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1666            .await;
1667
1668        debug_assert!(
1669            unavailable.is_empty(),
1670            "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1671        );
1672
1673        if !selected_notes.is_empty() {
1674            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");
1675        }
1676
1677        let mut selected_notes_decoded = vec![];
1678        for (amount, note) in selected_notes.iter_items() {
1679            let spendable_note_decoded = note.decode()?;
1680            debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1681            Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1682                .await;
1683            selected_notes_decoded.push((amount, spendable_note_decoded));
1684        }
1685
1686        let sender = self.balance_update_sender.clone();
1687        dbtx.on_commit(move || sender.send_replace(()));
1688
1689        self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1690    }
1691
1692    /// Create a mint input from external, potentially untrusted notes
1693    #[allow(clippy::type_complexity)]
1694    pub fn create_input_from_notes(
1695        &self,
1696        notes: TieredMulti<SpendableNote>,
1697    ) -> Result<Vec<(ClientInput<MintInput>, SpendableNote)>, ValidateNotesError> {
1698        let mut inputs_and_notes = Vec::new();
1699
1700        for (index, (amount, spendable_note)) in notes.into_iter_items().enumerate() {
1701            let key = self
1702                .cfg
1703                .tbs_pks
1704                .get(amount)
1705                .ok_or(ValidateNotesError::InvalidAmountTier { index, amount })?;
1706
1707            let note = spendable_note.note();
1708
1709            if !note.verify(*key) {
1710                return Err(ValidateNotesError::InvalidSignature { index });
1711            }
1712
1713            inputs_and_notes.push((
1714                ClientInput {
1715                    input: MintInput::new_v0(amount, note),
1716                    keys: vec![spendable_note.spend_key],
1717                    amounts: Amounts::new_bitcoin(amount),
1718                },
1719                spendable_note,
1720            ));
1721        }
1722
1723        Ok(inputs_and_notes)
1724    }
1725
1726    async fn spend_notes_oob(
1727        &self,
1728        dbtx: &mut DatabaseTransaction<'_>,
1729        notes_selector: &impl NotesSelector,
1730        amount: Amount,
1731        try_cancel_after: Option<Duration>,
1732    ) -> Result<
1733        (
1734            OperationId,
1735            Vec<MintClientStateMachines>,
1736            TieredMulti<SpendableNote>,
1737        ),
1738        SpendOOBError,
1739    > {
1740        if amount == Amount::ZERO {
1741            return Err(SpendOOBError::ZeroAmount);
1742        }
1743
1744        let selected_notes =
1745            Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1746
1747        let operation_id = spendable_notes_to_operation_id(&selected_notes);
1748
1749        for (amount, note) in selected_notes.iter_items() {
1750            debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1751            MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1752        }
1753
1754        let sender = self.balance_update_sender.clone();
1755        dbtx.on_commit(move || sender.send_replace(()));
1756
1757        let try_cancel_after = try_cancel_after.unwrap_or(OOB_SPEND_NO_TIMEOUT);
1758        let state_machines = if try_cancel_after == OOB_SPEND_NO_TIMEOUT {
1759            vec![]
1760        } else {
1761            vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1762                operation_id,
1763                state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1764                    spendable_notes: selected_notes.clone().into_iter_items().collect(),
1765                    timeout: fedimint_core::time::now() + try_cancel_after,
1766                }),
1767            })]
1768        };
1769
1770        Ok((operation_id, state_machines, selected_notes))
1771    }
1772
1773    async fn is_no_timeout_oob_spend(
1774        &self,
1775        operation_id: OperationId,
1776    ) -> Result<bool, SubscribeSpendNotesError> {
1777        let operation = self.mint_operation(operation_id).await?;
1778        let MintOperationMetaVariant::SpendOOB { no_timeout, .. } =
1779            operation.meta::<MintOperationMeta>().variant
1780        else {
1781            return Err(SubscribeSpendNotesError::NotAnOutOfBandSpend);
1782        };
1783
1784        Ok(no_timeout)
1785    }
1786
1787    pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1788        if self
1789            .is_no_timeout_oob_spend(operation_id)
1790            .await
1791            .unwrap_or(false)
1792        {
1793            return SpendOOBRefund {
1794                user_triggered: false,
1795                transaction_ids: vec![],
1796            };
1797        }
1798
1799        Box::pin(
1800            self.notifier
1801                .subscribe(operation_id)
1802                .await
1803                .filter_map(|state| async {
1804                    let MintClientStateMachines::OOB(state) = state else {
1805                        return None;
1806                    };
1807
1808                    match state.state {
1809                        MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1810                            user_triggered: false,
1811                            transaction_ids: vec![refund.refund_txid],
1812                        }),
1813                        MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1814                            user_triggered: true,
1815                            transaction_ids: vec![refund.refund_txid],
1816                        }),
1817                        MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1818                            user_triggered: true,
1819                            transaction_ids: vec![refund.refund_txid],
1820                        }),
1821                        MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1822                    }
1823                }),
1824        )
1825        .next_or_pending()
1826        .await
1827    }
1828
1829    /// Select notes with `requested_amount` using `notes_selector`.
1830    async fn select_notes(
1831        dbtx: &mut DatabaseTransaction<'_>,
1832        notes_selector: &impl NotesSelector,
1833        requested_amount: Amount,
1834        fee_consensus: FeeConsensus,
1835    ) -> Result<TieredMulti<SpendableNote>, SelectNotesError> {
1836        let note_stream = dbtx
1837            .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1838            .await
1839            .map(|(key, note)| (key.amount, note));
1840
1841        notes_selector
1842            .select_notes(note_stream, requested_amount, fee_consensus)
1843            .await?
1844            .into_iter_items()
1845            .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1846            .collect::<Result<TieredMulti<_>, SelectNotesError>>()
1847    }
1848
1849    async fn get_all_spendable_notes(
1850        dbtx: &mut DatabaseTransaction<'_>,
1851    ) -> TieredMulti<SpendableNoteUndecoded> {
1852        (dbtx
1853            .find_by_prefix(&NoteKeyPrefix)
1854            .await
1855            .map(|(key, note)| (key.amount, note))
1856            .collect::<Vec<_>>()
1857            .await)
1858            .into_iter()
1859            .collect()
1860    }
1861
1862    async fn get_next_note_index(
1863        &self,
1864        dbtx: &mut DatabaseTransaction<'_>,
1865        amount: Amount,
1866    ) -> NoteIndex {
1867        NoteIndex(
1868            dbtx.get_value(&NextECashNoteIndexKey(amount))
1869                .await
1870                .unwrap_or(0),
1871        )
1872    }
1873
1874    /// Derive the note `DerivableSecret` from the Mint's `secret` the `amount`
1875    /// tier and `note_idx`
1876    ///
1877    /// Static to help re-use in other places, that don't have a whole [`Self`]
1878    /// available
1879    ///
1880    /// # E-Cash Note Creation
1881    ///
1882    /// When creating an e-cash note, the `MintClientModule` first derives the
1883    /// blinding and spend keys from the `DerivableSecret`. It then creates a
1884    /// `NoteIssuanceRequest` containing the blinded spend key and sends it to
1885    /// the mint server. The mint server signs the blinded spend key and
1886    /// returns it to the client. The client can then unblind the signed
1887    /// spend key to obtain the e-cash note, which can be spent using the
1888    /// spend key.
1889    pub fn new_note_secret_static(
1890        secret: &DerivableSecret,
1891        amount: Amount,
1892        note_idx: NoteIndex,
1893    ) -> DerivableSecret {
1894        assert_eq!(secret.level(), 2);
1895        debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1896        secret
1897            .child_key(MINT_E_CASH_TYPE_CHILD_ID) // TODO: cache
1898            .child_key(ChildId(note_idx.as_u64()))
1899            .child_key(ChildId(amount.msats))
1900    }
1901
1902    /// We always keep track of an incrementing index in the database and use
1903    /// it as part of the derivation path for the note secret. This ensures that
1904    /// we never reuse the same note secret twice.
1905    async fn new_note_secret(
1906        &self,
1907        amount: Amount,
1908        dbtx: &mut DatabaseTransaction<'_>,
1909    ) -> DerivableSecret {
1910        let new_idx = self.get_next_note_index(dbtx, amount).await;
1911        dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1912            .await;
1913        Self::new_note_secret_static(&self.secret, amount, new_idx)
1914    }
1915
1916    pub async fn new_ecash_note(
1917        &self,
1918        amount: Amount,
1919        dbtx: &mut DatabaseTransaction<'_>,
1920    ) -> (NoteIssuanceRequest, BlindNonce) {
1921        let secret = self.new_note_secret(amount, dbtx).await;
1922        NoteIssuanceRequest::new(&self.secp, &secret)
1923    }
1924
1925    /// Computes the exact fee `reissue_external_notes(oob_notes)` would incur
1926    /// given the wallet's current note inventory, without submitting anything.
1927    ///
1928    /// Runs the same change generation the real reissue does
1929    /// (`create_final_inputs_and_outputs`, including note consolidation)
1930    /// against a non-committable transaction that is dropped rather than
1931    /// committed, so the wallet's notes are read but left untouched. The
1932    /// quote is point-in-time: it depends on the current inventory and can
1933    /// move as notes change.
1934    pub async fn reissue_fee_quote(
1935        &self,
1936        oob_notes: &OOBNotes,
1937    ) -> Result<FeeQuote, TransactionSubmitError> {
1938        // A reissue submits the external notes as explicit inputs and no explicit
1939        // outputs; the shared, module-agnostic fee quote runs the primary-module
1940        // balancing (note consolidation + minting change) over the real
1941        // inventory.
1942        let input_amount = oob_notes.total_amount();
1943        let input_fee: Amount = oob_notes
1944            .notes()
1945            .iter_items()
1946            .map(|(amount, _)| self.cfg.fee_consensus.fee(amount))
1947            .sum();
1948
1949        self.client_ctx
1950            .fee_quote(
1951                OperationId::new_random(),
1952                FeeQuoteRequest {
1953                    input_amount: Amounts::new_bitcoin(input_amount),
1954                    output_amount: Amounts::ZERO,
1955                    input_fee: Amounts::new_bitcoin(input_fee),
1956                    output_fee: Amounts::ZERO,
1957                },
1958            )
1959            .await
1960    }
1961
1962    /// Computes the fee a `send_oob_notes(amount)` would incur given the
1963    /// wallet's current note inventory, without sending anything.
1964    ///
1965    /// A send is free when the wallet's existing notes can cover the (rounded)
1966    /// amount exactly — it just hands those notes out. Otherwise the send first
1967    /// reissues itself the right denominations, and that self-reissue
1968    /// transaction is the only thing a send ever pays a fee for. This quote
1969    /// mirrors that: it returns [`FeeQuote::ZERO`] when exact change is
1970    /// available, and otherwise quotes the reissue the same way the real send
1971    /// submits it (explicit outputs representing `amount`, no explicit inputs)
1972    /// via the shared, module-agnostic fee quote over the real inventory. The
1973    /// quote is point-in-time: it depends on the current inventory and can move
1974    /// as notes change.
1975    pub async fn send_fee_quote(&self, amount: Amount) -> Result<FeeQuote, TransactionSubmitError> {
1976        let amount = self.cfg.fee_consensus.round_up(amount);
1977
1978        let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1979
1980        // Exact-change path: handing out existing notes never costs a fee. This
1981        // is the same selection `send_oob_notes` tries first (see
1982        // `try_spend_exact_notes_dbtx`).
1983        if Self::select_notes(
1984            &mut dbtx,
1985            &SelectNotesWithExactAmount,
1986            amount,
1987            FeeConsensus::zero(),
1988        )
1989        .await
1990        .is_ok()
1991        {
1992            return Ok(FeeQuote::ZERO);
1993        }
1994
1995        drop(dbtx);
1996
1997        // Reissue path: the send mints itself notes worth exactly `amount` as
1998        // explicit outputs (no explicit inputs) and the primary module funds and
1999        // balances it. Quote that exact transaction — the same exact
2000        // decomposition the real send's `create_exact_output` uses, so a single
2001        // reissue covers it.
2002        let denominations = self.represent_exact_amount(amount);
2003
2004        let output_amount = denominations.total_amount();
2005        let output_fee: Amount = denominations
2006            .iter()
2007            .map(|(denomination, count)| self.cfg.fee_consensus.fee(denomination) * count as u64)
2008            .sum();
2009
2010        self.client_ctx
2011            .fee_quote(
2012                OperationId::new_random(),
2013                FeeQuoteRequest {
2014                    input_amount: Amounts::ZERO,
2015                    output_amount: Amounts::new_bitcoin(output_amount),
2016                    input_fee: Amounts::ZERO,
2017                    output_fee: Amounts::new_bitcoin(output_fee),
2018                },
2019            )
2020            .await
2021    }
2022
2023    /// Try to reissue e-cash notes received from a third party to receive them
2024    /// in our wallet. The progress and outcome can be observed using
2025    /// [`MintClientModule::subscribe_reissue_external_notes`].
2026    ///
2027    /// ## Errors
2028    ///
2029    /// - [`ReissueExternalNotesError::ZeroAmount`] if the notes are worth
2030    ///   nothing.
2031    /// - [`ReissueExternalNotesError::WrongFederationId`] if the notes were
2032    ///   issued by a different federation.
2033    /// - [`ReissueExternalNotesError::AlreadyReissued`] if these exact notes
2034    ///   were already reissued.
2035    /// - [`ReissueExternalNotesError::Notes`] if the notes cannot be validated.
2036    /// - [`ReissueExternalNotesError::Transaction`] if the reissue transaction
2037    ///   could not be built or submitted.
2038    pub async fn reissue_external_notes<M: Serialize + Send>(
2039        &self,
2040        oob_notes: OOBNotes,
2041        extra_meta: M,
2042    ) -> Result<OperationId, ReissueExternalNotesError> {
2043        let notes = oob_notes.notes().clone();
2044        let federation_id_prefix = oob_notes.federation_id_prefix();
2045
2046        debug!(
2047            target: LOG_CLIENT_MODULE_MINT,
2048            notes = ?notes
2049                .iter_items()
2050                .map(|(amount, note)| (amount, note.nonce()))
2051                .collect::<Vec<_>>(),
2052            "Reissuing external notes"
2053        );
2054
2055        if notes.total_amount() == Amount::ZERO {
2056            return Err(ReissueExternalNotesError::ZeroAmount);
2057        }
2058
2059        if federation_id_prefix != self.federation_id.to_prefix() {
2060            return Err(ReissueExternalNotesError::WrongFederationId {
2061                expected: self.federation_id.to_prefix(),
2062                found: federation_id_prefix,
2063            });
2064        }
2065
2066        let operation_id = OperationId(
2067            notes
2068                .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
2069                .to_byte_array(),
2070        );
2071
2072        let amount = notes.total_amount();
2073        let mint_inputs = self.create_input_from_notes(notes)?;
2074
2075        let tx = TransactionBuilder::new().with_inputs(
2076            self.client_ctx
2077                .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
2078        );
2079
2080        let extra_meta = serde_json::to_value(extra_meta)
2081            .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
2082        let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
2083            variant: MintOperationMetaVariant::Reissuance {
2084                legacy_out_point: None,
2085                txid: Some(change_range.txid()),
2086                out_point_indices: change_range
2087                    .into_iter()
2088                    .map(|out_point| out_point.out_idx)
2089                    .collect(),
2090            },
2091            amount,
2092            extra_meta: extra_meta.clone(),
2093        };
2094
2095        self.client_ctx
2096            .finalize_and_submit_transaction(
2097                operation_id,
2098                MintCommonInit::KIND.as_str(),
2099                operation_meta_gen,
2100                tx,
2101            )
2102            .await
2103            .map_err(|error| match error {
2104                TransactionSubmitError::OperationAlreadyExists(_) => {
2105                    ReissueExternalNotesError::AlreadyReissued
2106                }
2107                error => ReissueExternalNotesError::Transaction(error),
2108            })?;
2109
2110        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2111
2112        self.client_ctx
2113            .log_event(&mut dbtx, OOBNotesReissued { amount })
2114            .await;
2115
2116        self.client_ctx
2117            .log_event(
2118                &mut dbtx,
2119                ReceivePaymentEvent {
2120                    operation_id,
2121                    amount,
2122                },
2123            )
2124            .await;
2125
2126        dbtx.commit_tx().await;
2127
2128        Ok(operation_id)
2129    }
2130
2131    /// Subscribe to updates on the progress of a reissue operation started with
2132    /// [`MintClientModule::reissue_external_notes`].
2133    pub async fn subscribe_reissue_external_notes(
2134        &self,
2135        operation_id: OperationId,
2136    ) -> SubscribeReissueExternalNotesResult {
2137        let operation = self.mint_operation(operation_id).await?;
2138        let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
2139            MintOperationMetaVariant::Reissuance {
2140                legacy_out_point,
2141                txid,
2142                out_point_indices,
2143            } => {
2144                // Either txid or legacy_out_point will be present, so we should always
2145                // have a source for the txid
2146                let txid = txid
2147                    .or(legacy_out_point.map(|out_point| out_point.txid))
2148                    .ok_or(SubscribeReissueExternalNotesError::NoTransaction)?;
2149
2150                let out_points = out_point_indices
2151                    .into_iter()
2152                    .map(|out_idx| OutPoint { txid, out_idx })
2153                    .chain(legacy_out_point)
2154                    .collect::<Vec<_>>();
2155
2156                (txid, out_points)
2157            }
2158            MintOperationMetaVariant::SpendOOB { .. } => {
2159                return Err(SubscribeReissueExternalNotesError::NotAReissuance);
2160            }
2161        };
2162
2163        let client_ctx = self.client_ctx.clone();
2164
2165        Ok(self.client_ctx.outcome_or_updates(
2166            &operation,
2167            operation_id,
2168            |state| match state {
2169                ReissueExternalNotesState::Created | ReissueExternalNotesState::Issuing => false,
2170                ReissueExternalNotesState::Done | ReissueExternalNotesState::Failed(_) => true,
2171            },
2172            move || {
2173            stream! {
2174                yield ReissueExternalNotesState::Created;
2175
2176                match client_ctx
2177                    .transaction_updates(operation_id)
2178                    .await
2179                    .await_tx_accepted(txid)
2180                    .await
2181                {
2182                    Ok(()) => {
2183                        yield ReissueExternalNotesState::Issuing;
2184                    }
2185                    Err(e) => {
2186                        yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
2187                        return;
2188                    }
2189                }
2190
2191                for out_point in out_points {
2192                    if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
2193                        yield ReissueExternalNotesState::Failed(e.fmt_compact().to_string());
2194                        return;
2195                    }
2196                }
2197                yield ReissueExternalNotesState::Done;
2198            }}
2199        ))
2200    }
2201
2202    /// Fetches and removes notes of *at least* amount `min_amount` from the
2203    /// wallet to be sent to the recipient out of band. These spends can be
2204    /// canceled by calling [`MintClientModule::try_cancel_spend_notes`] as long
2205    /// as the recipient hasn't reissued the e-cash notes themselves yet.
2206    ///
2207    /// The client will also automatically attempt to cancel the operation after
2208    /// `try_cancel_after` time has passed. This is a safety mechanism to avoid
2209    /// users forgetting about failed out-of-band transactions. The timeout
2210    /// should be chosen such that the recipient (who is potentially offline at
2211    /// the time of receiving the e-cash notes) had a reasonable timeframe to
2212    /// come online and reissue the notes themselves. Pass `None` to disable
2213    /// automatic cancellation.
2214    #[deprecated(
2215        since = "0.5.0",
2216        note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
2217    )]
2218    pub async fn spend_notes<M: Serialize + Send>(
2219        &self,
2220        min_amount: Amount,
2221        try_cancel_after: Option<Duration>,
2222        include_invite: bool,
2223        extra_meta: M,
2224    ) -> Result<(OperationId, OOBNotes), SpendOOBError> {
2225        self.spend_notes_with_selector(
2226            &SelectNotesWithAtleastAmount,
2227            min_amount,
2228            try_cancel_after,
2229            include_invite,
2230            extra_meta,
2231        )
2232        .await
2233    }
2234
2235    /// Fetches and removes notes from the wallet to be sent to the recipient
2236    /// out of band. The note selection algorithm is determined by
2237    /// `note_selector`. See the [`NotesSelector`] trait for available
2238    /// implementations.
2239    ///
2240    /// These spends can be canceled by calling
2241    /// [`MintClientModule::try_cancel_spend_notes`] as long
2242    /// as the recipient hasn't reissued the e-cash notes themselves yet.
2243    ///
2244    /// The client will also automatically attempt to cancel the operation after
2245    /// `try_cancel_after` time has passed. This is a safety mechanism to avoid
2246    /// users forgetting about failed out-of-band transactions. The timeout
2247    /// should be chosen such that the recipient (who is potentially offline at
2248    /// the time of receiving the e-cash notes) had a reasonable timeframe to
2249    /// come online and reissue the notes themselves. Pass `None` to disable
2250    /// automatic cancellation.
2251    pub async fn spend_notes_with_selector<M: Serialize + Send>(
2252        &self,
2253        notes_selector: &impl NotesSelector,
2254        requested_amount: Amount,
2255        try_cancel_after: Option<Duration>,
2256        include_invite: bool,
2257        extra_meta: M,
2258    ) -> Result<(OperationId, OOBNotes), SpendOOBError> {
2259        let federation_id_prefix = self.federation_id.to_prefix();
2260        let extra_meta = serde_json::to_value(extra_meta)
2261            .expect("MintClientModule::spend_notes extra_meta is serializable");
2262
2263        self.client_ctx
2264            .module_db()
2265            .autocommit(
2266                |dbtx, _| {
2267                    let extra_meta = extra_meta.clone();
2268                    Box::pin(async {
2269                        let no_timeout = try_cancel_after.is_none();
2270                        let (operation_id, states, notes) = self
2271                            .spend_notes_oob(
2272                                dbtx,
2273                                notes_selector,
2274                                requested_amount,
2275                                try_cancel_after,
2276                            )
2277                            .await?;
2278
2279                        let oob_notes = if include_invite {
2280                            OOBNotes::new_with_invite(
2281                                notes,
2282                                &self.client_ctx.get_invite_code().await,
2283                            )
2284                        } else {
2285                            OOBNotes::new(federation_id_prefix, notes)
2286                        };
2287
2288                        self.client_ctx
2289                            .add_state_machines_dbtx(
2290                                dbtx,
2291                                self.client_ctx.map_dyn(states).collect(),
2292                            )
2293                            .await?;
2294                        self.client_ctx
2295                            .add_operation_log_entry_dbtx(
2296                                dbtx,
2297                                operation_id,
2298                                MintCommonInit::KIND.as_str(),
2299                                MintOperationMeta {
2300                                    variant: MintOperationMetaVariant::SpendOOB {
2301                                        requested_amount,
2302                                        oob_notes: oob_notes.clone(),
2303                                        no_timeout,
2304                                    },
2305                                    amount: oob_notes.total_amount(),
2306                                    extra_meta,
2307                                },
2308                            )
2309                            .await;
2310                        self.client_ctx
2311                            .log_event(
2312                                dbtx,
2313                                OOBNotesSpent {
2314                                    requested_amount,
2315                                    spent_amount: oob_notes.total_amount(),
2316                                    timeout: try_cancel_after,
2317                                    include_invite,
2318                                },
2319                            )
2320                            .await;
2321
2322                        self.client_ctx
2323                            .log_event(
2324                                dbtx,
2325                                SendPaymentEvent {
2326                                    operation_id,
2327                                    amount: oob_notes.total_amount(),
2328                                    oob_notes: encode_prefixed(FEDIMINT_PREFIX, &oob_notes),
2329                                },
2330                            )
2331                            .await;
2332
2333                        Ok::<_, SpendOOBError>((operation_id, oob_notes))
2334                    })
2335                },
2336                Some(100),
2337            )
2338            .await
2339            .map_err(|e| match e {
2340                AutocommitError::ClosureError { error, .. } => error,
2341                AutocommitError::CommitFailed { last_error, .. } => {
2342                    SpendOOBError::Database(last_error)
2343                }
2344            })
2345    }
2346
2347    /// Send e-cash notes for the requested amount.
2348    ///
2349    /// When this method removes ecash notes from the local database it will do
2350    /// so atomically with creating a `SendPaymentEvent` that contains the notes
2351    /// in out of band serilaized from. Hence it is critical for the integrator
2352    /// to display this event to ensure the user always has access to his funds.
2353    ///
2354    /// This method operates in two modes:
2355    ///
2356    /// 1. **Offline mode**: If exact notes are available in the wallet, they
2357    ///    are spent immediately without contacting the federation. A
2358    ///    `SendPaymentEvent` is emitted and the notes are returned.
2359    ///
2360    /// 2. **Online mode**: If exact notes are not available, the method
2361    ///    contacts the federation to trigger a reissuance transaction to obtain
2362    ///    the proper denominations. The method will block until the reissuance
2363    ///    completes, at which point a `SendPaymentEvent` is emitted and the
2364    ///    notes are returned.
2365    ///
2366    /// If the method enters online mode and is cancelled, e.g. the future is
2367    /// dropped, before the reissue transaction is confirmed, any reissued notes
2368    /// will be returned to the wallet and we do not emit a `SendPaymentEvent`.
2369    ///
2370    /// If the federation charges fees, the amount is rounded up to the nearest
2371    /// multiple of the smallest economical denomination before selection of the
2372    /// ecash notes.
2373    pub async fn send_oob_notes<M: Serialize + Send>(
2374        &self,
2375        amount: Amount,
2376        extra_meta: M,
2377    ) -> Result<OOBNotes, SendOOBNotesError> {
2378        let amount = self.cfg.fee_consensus.round_up(amount);
2379
2380        let extra_meta = serde_json::to_value(extra_meta)
2381            .expect("MintClientModule::send_oob_notes extra_meta is serializable");
2382
2383        // Try to spend exact notes from our current balance
2384        let oob_notes: Option<OOBNotes> = self
2385            .client_ctx
2386            .module_db()
2387            .autocommit(
2388                |dbtx, _| {
2389                    let extra_meta = extra_meta.clone();
2390                    Box::pin(async {
2391                        Ok::<Option<OOBNotes>, SendOOBNotesError>(
2392                            self.try_spend_exact_notes_dbtx(
2393                                dbtx,
2394                                amount,
2395                                self.federation_id,
2396                                extra_meta,
2397                            )
2398                            .await,
2399                        )
2400                    })
2401                },
2402                Some(100),
2403            )
2404            .await
2405            .map_err(|e| match e {
2406                AutocommitError::ClosureError { error, .. } => error,
2407                AutocommitError::CommitFailed { last_error, .. } => {
2408                    SendOOBNotesError::Database(last_error)
2409                }
2410            })?;
2411
2412        if let Some(oob_notes) = oob_notes {
2413            return Ok(oob_notes);
2414        }
2415
2416        // Verify we're online
2417        self.client_ctx.global_api().session_count().await?;
2418
2419        let operation_id = OperationId::new_random();
2420
2421        // Reissue ourselves notes worth *exactly* `amount` (fee funded by the
2422        // balancing layer), so the retry below can hand out the exact amount in a
2423        // single reissue — rather than minting `amount` minus fees and having to
2424        // reissue repeatedly to make up the difference. Commit the note index
2425        // counter updates so create_final_inputs_and_outputs won't reuse the same
2426        // indices for change outputs.
2427        let output_bundle = self
2428            .client_ctx
2429            .module_db()
2430            .autocommit(
2431                |dbtx, _| {
2432                    Box::pin(async {
2433                        Ok::<_, SendOOBNotesError>(
2434                            self.create_exact_output(dbtx, operation_id, amount).await,
2435                        )
2436                    })
2437                },
2438                Some(100),
2439            )
2440            .await
2441            .map_err(|e| match e {
2442                AutocommitError::ClosureError { error, .. } => error,
2443                AutocommitError::CommitFailed { last_error, .. } => {
2444                    SendOOBNotesError::Database(last_error)
2445                }
2446            })?;
2447
2448        // The explicit outputs we just minted (worth exactly `amount`) occupy the
2449        // first `explicit_output_count` out points of the transaction; the
2450        // primary module's change is appended after them. The recursion below
2451        // hands out these exact notes, so we must wait for *them* to finalize —
2452        // not just the change.
2453        let explicit_output_count = output_bundle.outputs().len() as u64;
2454
2455        // Combine the output bundle state machines with the send state machine
2456        let combined_bundle = ClientOutputBundle::new(
2457            output_bundle.outputs().to_vec(),
2458            output_bundle.sms().to_vec(),
2459        );
2460
2461        let outputs = self.client_ctx.make_client_outputs(combined_bundle);
2462
2463        let em_clone = extra_meta.clone();
2464
2465        // Submit reissuance transaction with the state machines
2466        let out_point_range = self
2467            .client_ctx
2468            .finalize_and_submit_transaction(
2469                operation_id,
2470                MintCommonInit::KIND.as_str(),
2471                move |change_range: OutPointRange| MintOperationMeta {
2472                    variant: MintOperationMetaVariant::Reissuance {
2473                        legacy_out_point: None,
2474                        txid: Some(change_range.txid()),
2475                        out_point_indices: change_range
2476                            .into_iter()
2477                            .map(|out_point| out_point.out_idx)
2478                            .collect(),
2479                    },
2480                    amount,
2481                    extra_meta: em_clone.clone(),
2482                },
2483                TransactionBuilder::new().with_outputs(outputs),
2484            )
2485            .await?;
2486
2487        // Wait for *all* of the transaction's outputs to be finalized — both the
2488        // change (returned in `out_point_range`) and the explicit exact-amount
2489        // notes at out points `[0, explicit_output_count)`. The recursion below
2490        // can only hand out the exact notes once they are spendable; awaiting
2491        // only the change (as before) raced the recursion against issuance,
2492        // causing it to re-reissue and drain the wallet.
2493        let txid = out_point_range.txid();
2494        let total_output_count = explicit_output_count + out_point_range.count() as u64;
2495        let all_outputs = OutPointRange::new(txid, IdxRange::from(0..total_output_count));
2496        self.client_ctx
2497            .await_primary_module_outputs(operation_id, all_outputs.into_iter().collect())
2498            .await?;
2499
2500        // Recursively call send_oob_notes to try again with the reissued notes
2501        Box::pin(self.send_oob_notes(amount, extra_meta)).await
2502    }
2503
2504    /// Try to spend exact notes from the current balance.
2505    /// Returns `Some(OOBNotes)` if exact notes are available, `None` otherwise.
2506    async fn try_spend_exact_notes_dbtx(
2507        &self,
2508        dbtx: &mut DatabaseTransaction<'_>,
2509        amount: Amount,
2510        federation_id: FederationId,
2511        extra_meta: serde_json::Value,
2512    ) -> Option<OOBNotes> {
2513        let selected_notes = Self::select_notes(
2514            dbtx,
2515            &SelectNotesWithExactAmount,
2516            amount,
2517            FeeConsensus::zero(),
2518        )
2519        .await
2520        .ok()?;
2521
2522        // Remove notes from our database
2523        for (note_amount, note) in selected_notes.iter_items() {
2524            MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, note_amount, note)
2525                .await;
2526        }
2527
2528        let sender = self.balance_update_sender.clone();
2529        dbtx.on_commit(move || sender.send_replace(()));
2530
2531        let operation_id = spendable_notes_to_operation_id(&selected_notes);
2532
2533        let oob_notes = OOBNotes::new(federation_id.to_prefix(), selected_notes);
2534
2535        // Log the send operation with notes immediately available
2536        self.client_ctx
2537            .add_operation_log_entry_dbtx(
2538                dbtx,
2539                operation_id,
2540                MintCommonInit::KIND.as_str(),
2541                MintOperationMeta {
2542                    variant: MintOperationMetaVariant::SpendOOB {
2543                        requested_amount: amount,
2544                        oob_notes: oob_notes.clone(),
2545                        no_timeout: true,
2546                    },
2547                    amount: oob_notes.total_amount(),
2548                    extra_meta,
2549                },
2550            )
2551            .await;
2552
2553        self.client_ctx
2554            .log_event(
2555                dbtx,
2556                SendPaymentEvent {
2557                    operation_id,
2558                    amount: oob_notes.total_amount(),
2559                    oob_notes: encode_prefixed(FEDIMINT_PREFIX, &oob_notes),
2560                },
2561            )
2562            .await;
2563
2564        Some(oob_notes)
2565    }
2566
2567    /// Validate the given notes and return the total amount of the notes.
2568    /// Validation checks that:
2569    /// - the federation ID is correct
2570    /// - the note has a valid signature
2571    /// - the spend key is correct.
2572    pub fn validate_notes(&self, oob_notes: &OOBNotes) -> Result<Amount, ValidateNotesError> {
2573        let federation_id_prefix = oob_notes.federation_id_prefix();
2574        let notes = oob_notes.notes().clone();
2575
2576        let expected = self.federation_id.to_prefix();
2577        if federation_id_prefix != expected {
2578            return Err(ValidateNotesError::WrongFederationId {
2579                expected,
2580                found: federation_id_prefix,
2581            });
2582        }
2583
2584        let tbs_pks = &self.cfg.tbs_pks;
2585
2586        for (index, (amt, snote)) in notes.iter_items().enumerate() {
2587            let key = tbs_pks
2588                .get(amt)
2589                .ok_or(ValidateNotesError::InvalidAmountTier { index, amount: amt })?;
2590
2591            let note = snote.note();
2592            if !note.verify(*key) {
2593                return Err(ValidateNotesError::InvalidSignature { index });
2594            }
2595
2596            let expected_nonce = Nonce(snote.spend_key.public_key());
2597            if note.nonce != expected_nonce {
2598                return Err(ValidateNotesError::WrongSpendKey { index });
2599            }
2600        }
2601
2602        Ok(notes.total_amount())
2603    }
2604
2605    /// Contacts the mint and checks if the supplied notes were already spent.
2606    ///
2607    /// **Caution:** This reduces privacy and can lead to race conditions. **DO
2608    /// NOT** rely on it for receiving funds unless you really know what you are
2609    /// doing.
2610    pub async fn check_note_spent(&self, oob_notes: &OOBNotes) -> FederationResult<bool> {
2611        use crate::api::MintFederationApi;
2612
2613        let api_client = self.client_ctx.module_api();
2614        let any_spent = try_join_all(oob_notes.notes().iter().flat_map(|(_, notes)| {
2615            notes
2616                .iter()
2617                .map(|note| api_client.check_note_spent(note.nonce()))
2618        }))
2619        .await?
2620        .into_iter()
2621        .any(|spent| spent);
2622
2623        Ok(any_spent)
2624    }
2625
2626    /// Try to cancel a spend operation started with
2627    /// [`MintClientModule::spend_notes_with_selector`]. If the e-cash notes
2628    /// have already been spent this operation will fail which can be
2629    /// observed using [`MintClientModule::subscribe_spend_notes`].
2630    pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
2631        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2632        dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
2633            .await;
2634        if let Err(e) = dbtx.commit_tx_result().await {
2635            warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
2636        }
2637    }
2638
2639    /// Subscribe to updates on the progress of a raw e-cash spend operation
2640    /// started with [`MintClientModule::spend_notes_with_selector`].
2641    pub async fn subscribe_spend_notes(
2642        &self,
2643        operation_id: OperationId,
2644    ) -> Result<UpdateStreamOrOutcome<SpendOOBState>, SubscribeSpendNotesError> {
2645        let operation = self.mint_operation(operation_id).await?;
2646        let MintOperationMetaVariant::SpendOOB { no_timeout, .. } =
2647            operation.meta::<MintOperationMeta>().variant
2648        else {
2649            return Err(SubscribeSpendNotesError::NotAnOutOfBandSpend);
2650        };
2651
2652        let client_ctx = self.client_ctx.clone();
2653
2654        Ok(self.client_ctx.outcome_or_updates(
2655            &operation,
2656            operation_id,
2657            |state| match state {
2658                SpendOOBState::Created | SpendOOBState::UserCanceledProcessing => false,
2659                SpendOOBState::UserCanceledSuccess
2660                | SpendOOBState::UserCanceledFailure
2661                | SpendOOBState::Success
2662                | SpendOOBState::Refunded => true,
2663            },
2664            move || {
2665                stream! {
2666                    yield SpendOOBState::Created;
2667
2668                    if no_timeout {
2669                        yield SpendOOBState::Success;
2670                        return;
2671                    }
2672
2673                    let self_ref = client_ctx.self_ref();
2674
2675                    let refund = self_ref
2676                        .await_spend_oob_refund(operation_id)
2677                        .await;
2678
2679                    if refund.user_triggered {
2680                        yield SpendOOBState::UserCanceledProcessing;
2681                    }
2682
2683                    let mut success = true;
2684
2685                    for txid in refund.transaction_ids {
2686                        debug!(
2687                            target: LOG_CLIENT_MODULE_MINT,
2688                            %txid,
2689                            operation_id=%operation_id.fmt_short(),
2690                            "Waiting for oob refund txid"
2691                        );
2692                        if client_ctx
2693                            .transaction_updates(operation_id)
2694                            .await
2695                            .await_tx_accepted(txid)
2696                            .await.is_err() {
2697                                success = false;
2698                            }
2699                    }
2700
2701                    debug!(
2702                        target: LOG_CLIENT_MODULE_MINT,
2703                        operation_id=%operation_id.fmt_short(),
2704                        %success,
2705                        "Done waiting for all refund oob txids"
2706                     );
2707
2708                    match (refund.user_triggered, success) {
2709                        (true, true) => {
2710                            yield SpendOOBState::UserCanceledSuccess;
2711                        },
2712                        (true, false) => {
2713                            yield SpendOOBState::UserCanceledFailure;
2714                        },
2715                        (false, true) => {
2716                            yield SpendOOBState::Refunded;
2717                        },
2718                        (false, false) => {
2719                            yield SpendOOBState::Success;
2720                        }
2721                    }
2722                }
2723            },
2724        ))
2725    }
2726
2727    async fn mint_operation(
2728        &self,
2729        operation_id: OperationId,
2730    ) -> Result<OperationLogEntry, OperationLookupError> {
2731        self.client_ctx.get_operation(operation_id).await
2732    }
2733
2734    async fn delete_spendable_note(
2735        client_ctx: &ClientContext<MintClientModule>,
2736        dbtx: &mut DatabaseTransaction<'_>,
2737        amount: Amount,
2738        note: &SpendableNote,
2739    ) {
2740        client_ctx
2741            .log_event(
2742                dbtx,
2743                NoteSpent {
2744                    nonce: note.nonce(),
2745                },
2746            )
2747            .await;
2748        dbtx.remove_entry(&NoteKey {
2749            amount,
2750            nonce: note.nonce(),
2751        })
2752        .await
2753        .expect("Must deleted existing spendable note");
2754    }
2755
2756    pub async fn advance_note_idx(&self, amount: Amount) -> DerivableSecret {
2757        let db = self.client_ctx.module_db().clone();
2758
2759        db.autocommit(
2760            |dbtx, _| {
2761                Box::pin(async {
2762                    Ok::<DerivableSecret, std::convert::Infallible>(
2763                        self.new_note_secret(amount, dbtx).await,
2764                    )
2765                })
2766            },
2767            None,
2768        )
2769        .await
2770        .expect("The commit is retried until it succeeds and the closure cannot fail")
2771    }
2772
2773    /// Returns secrets for the note indices that were reused by previous
2774    /// clients with same client secret.
2775    pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
2776        self.client_ctx
2777            .module_db()
2778            .begin_transaction_nc()
2779            .await
2780            .get_value(&ReusedNoteIndices)
2781            .await
2782            .unwrap_or_default()
2783            .into_iter()
2784            .map(|(amount, note_idx)| {
2785                let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
2786                let (request, blind_nonce) =
2787                    NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
2788                (amount, request, blind_nonce)
2789            })
2790            .collect()
2791    }
2792}
2793
2794pub fn spendable_notes_to_operation_id(
2795    spendable_selected_notes: &TieredMulti<SpendableNote>,
2796) -> OperationId {
2797    OperationId(
2798        spendable_selected_notes
2799            .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
2800            .to_byte_array(),
2801    )
2802}
2803
2804#[derive(Debug, Serialize, Deserialize, Clone)]
2805#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
2806pub struct SpendOOBRefund {
2807    pub user_triggered: bool,
2808    /// Empty when the spend disabled automatic refunds and no refund was
2809    /// attempted.
2810    pub transaction_ids: Vec<TransactionId>,
2811}
2812
2813/// Defines a strategy for selecting e-cash notes given a specific target amount
2814/// and fee per note transaction input.
2815#[apply(async_trait_maybe_send!)]
2816pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
2817    /// Select notes from stream for `requested_amount`.
2818    /// The stream must produce items in non- decreasing order of amount.
2819    async fn select_notes(
2820        &self,
2821        // FIXME: async trait doesn't like maybe_add_send
2822        #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2823        #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2824        requested_amount: Amount,
2825        fee_consensus: FeeConsensus,
2826    ) -> Result<TieredMulti<Note>, SelectNotesError>;
2827}
2828
2829/// Select notes with total amount of *at least* `request_amount`. If more than
2830/// requested amount of notes are returned it was because exact change couldn't
2831/// be made, and the next smallest amount will be returned.
2832///
2833/// The caller can request change from the federation.
2834pub struct SelectNotesWithAtleastAmount;
2835
2836#[apply(async_trait_maybe_send!)]
2837impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
2838    async fn select_notes(
2839        &self,
2840        #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2841        #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2842        requested_amount: Amount,
2843        fee_consensus: FeeConsensus,
2844    ) -> Result<TieredMulti<Note>, SelectNotesError> {
2845        Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
2846    }
2847}
2848
2849/// Select notes with total amount of *exactly* `request_amount`. If the amount
2850/// cannot be represented with the available denominations an error is returned,
2851/// this **does not** mean that the balance is too low.
2852pub struct SelectNotesWithExactAmount;
2853
2854#[apply(async_trait_maybe_send!)]
2855impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
2856    async fn select_notes(
2857        &self,
2858        #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2859        #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2860        requested_amount: Amount,
2861        fee_consensus: FeeConsensus,
2862    ) -> Result<TieredMulti<Note>, SelectNotesError> {
2863        let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
2864
2865        let selected = notes.total_amount();
2866        if selected != requested_amount {
2867            return Err(SelectNotesError::NoExactAmount {
2868                requested: requested_amount,
2869                selected,
2870            });
2871        }
2872
2873        Ok(notes)
2874    }
2875}
2876
2877// We are using a greedy algorithm to select notes. We start with the largest
2878// then proceed to the lowest tiers/denominations.
2879// But there is a catch: we don't know if there are enough notes in the lowest
2880// tiers, so we need to save a big note in case the sum of the following
2881// small notes are not enough.
2882async fn select_notes_from_stream<Note>(
2883    stream: impl futures::Stream<Item = (Amount, Note)>,
2884    requested_amount: Amount,
2885    fee_consensus: FeeConsensus,
2886) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
2887    if requested_amount == Amount::ZERO {
2888        return Ok(TieredMulti::default());
2889    }
2890    let mut stream = Box::pin(stream);
2891    let mut selected = vec![];
2892    // This is the big note we save in case the sum of the following small notes are
2893    // not sufficient to cover the pending amount
2894    // The tuple is (amount, note, checkpoint), where checkpoint is the index where
2895    // the note should be inserted on the selected vector if it is needed
2896    let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
2897    let mut pending_amount = requested_amount;
2898    let mut previous_amount: Option<Amount> = None; // used to assert descending order
2899    loop {
2900        if let Some((note_amount, note)) = stream.next().await {
2901            assert!(
2902                previous_amount.is_none_or(|previous| previous >= note_amount),
2903                "notes are not sorted in descending order"
2904            );
2905            previous_amount = Some(note_amount);
2906
2907            if note_amount <= fee_consensus.fee(note_amount) {
2908                continue;
2909            }
2910
2911            match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2912                Ordering::Less => {
2913                    // keep adding notes until we have enough
2914                    pending_amount += fee_consensus.fee(note_amount);
2915                    pending_amount -= note_amount;
2916                    selected.push((note_amount, note));
2917                }
2918                Ordering::Greater => {
2919                    // probably we don't need this big note, but we'll keep it in case the
2920                    // following small notes don't add up to the
2921                    // requested amount
2922                    last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2923                }
2924                Ordering::Equal => {
2925                    // exactly enough notes, return
2926                    selected.push((note_amount, note));
2927
2928                    let notes: TieredMulti<Note> = selected.into_iter().collect();
2929
2930                    assert!(
2931                        notes.total_amount().msats
2932                            >= requested_amount.msats
2933                                + notes
2934                                    .iter()
2935                                    .map(|note| fee_consensus.fee(note.0))
2936                                    .sum::<Amount>()
2937                                    .msats
2938                    );
2939
2940                    return Ok(notes);
2941                }
2942            }
2943        } else {
2944            assert!(pending_amount > Amount::ZERO);
2945            if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2946                // the sum of the small notes don't add up to the pending amount, remove
2947                // them
2948                selected.truncate(checkpoint);
2949                // and use the big note to cover it
2950                selected.push((big_note_amount, big_note));
2951
2952                let notes: TieredMulti<Note> = selected.into_iter().collect();
2953
2954                assert!(
2955                    notes.total_amount().msats
2956                        >= requested_amount.msats
2957                            + notes
2958                                .iter()
2959                                .map(|note| fee_consensus.fee(note.0))
2960                                .sum::<Amount>()
2961                                .msats
2962                );
2963
2964                // so now we have enough to cover the requested amount, return
2965                return Ok(notes);
2966            }
2967
2968            let total_amount = requested_amount.saturating_sub(pending_amount);
2969            // not enough notes, return
2970            return Err(InsufficientBalanceError {
2971                requested_amount,
2972                total_amount,
2973            });
2974        }
2975    }
2976}
2977
2978/// Old and no longer used, will be deleted in the future
2979#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2980enum MintRestoreStates {
2981    #[encodable_default]
2982    Default { variant: u64, bytes: Vec<u8> },
2983}
2984
2985/// Old and no longer used, will be deleted in the future
2986#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2987pub struct MintRestoreStateMachine {
2988    operation_id: OperationId,
2989    state: MintRestoreStates,
2990}
2991
2992#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2993pub enum MintClientStateMachines {
2994    Output(MintOutputStateMachine),
2995    Input(MintInputStateMachine),
2996    OOB(MintOOBStateMachine),
2997    // Removed in https://github.com/fedimint/fedimint/pull/4035 , now ignored
2998    Restore(MintRestoreStateMachine),
2999}
3000
3001impl IntoDynInstance for MintClientStateMachines {
3002    type DynType = DynState;
3003
3004    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
3005        DynState::from_typed(instance_id, self)
3006    }
3007}
3008
3009impl State for MintClientStateMachines {
3010    type ModuleContext = MintClientContext;
3011
3012    fn transitions(
3013        &self,
3014        context: &Self::ModuleContext,
3015        global_context: &DynGlobalClientContext,
3016    ) -> Vec<StateTransition<Self>> {
3017        match self {
3018            MintClientStateMachines::Output(issuance_state) => {
3019                sm_enum_variant_translation!(
3020                    issuance_state.transitions(context, global_context),
3021                    MintClientStateMachines::Output
3022                )
3023            }
3024            MintClientStateMachines::Input(redemption_state) => {
3025                sm_enum_variant_translation!(
3026                    redemption_state.transitions(context, global_context),
3027                    MintClientStateMachines::Input
3028                )
3029            }
3030            MintClientStateMachines::OOB(oob_state) => {
3031                sm_enum_variant_translation!(
3032                    oob_state.transitions(context, global_context),
3033                    MintClientStateMachines::OOB
3034                )
3035            }
3036            MintClientStateMachines::Restore(_) => {
3037                sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
3038            }
3039        }
3040    }
3041
3042    fn operation_id(&self) -> OperationId {
3043        match self {
3044            MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
3045            MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
3046            MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
3047            MintClientStateMachines::Restore(r) => r.operation_id,
3048        }
3049    }
3050
3051    fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
3052        match self {
3053            MintClientStateMachines::Output(s) => s.fmt_visualization(f, indent),
3054            MintClientStateMachines::Input(s) => s.fmt_visualization(f, indent),
3055            MintClientStateMachines::OOB(s) => s.fmt_visualization(f, indent),
3056            MintClientStateMachines::Restore(_) => write!(f, "{indent}{self:?}"),
3057        }
3058    }
3059}
3060
3061/// A [`Note`] with associated secret key that allows to proof ownership (spend
3062/// it)
3063#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
3064pub struct SpendableNote {
3065    pub signature: tbs::Signature,
3066    pub spend_key: Keypair,
3067}
3068
3069impl fmt::Debug for SpendableNote {
3070    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3071        f.debug_struct("SpendableNote")
3072            .field("nonce", &self.nonce())
3073            .field("signature", &self.signature)
3074            .field("spend_key", &self.spend_key)
3075            .finish()
3076    }
3077}
3078impl fmt::Display for SpendableNote {
3079    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3080        write!(f, "{}", self.nonce().fmt_short())
3081    }
3082}
3083
3084impl SpendableNote {
3085    pub fn nonce(&self) -> Nonce {
3086        Nonce(self.spend_key.public_key())
3087    }
3088
3089    fn note(&self) -> Note {
3090        Note {
3091            nonce: self.nonce(),
3092            signature: self.signature,
3093        }
3094    }
3095
3096    pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
3097        SpendableNoteUndecoded {
3098            signature: self
3099                .signature
3100                .consensus_encode_to_vec()
3101                .try_into()
3102                .expect("Encoded size always correct"),
3103            spend_key: self.spend_key,
3104        }
3105    }
3106}
3107
3108/// A version of [`SpendableNote`] that didn't decode the `signature` yet
3109///
3110/// **Note**: signature decoding from raw bytes is faliable, as not all bytes
3111/// are valid signatures. Therefore this type must not be used for external
3112/// data, and should be limited to optimizing reading from internal database.
3113///
3114/// The signature bytes will be validated in [`Self::decode`].
3115///
3116/// Decoding [`tbs::Signature`] is somewhat CPU-intensive (see benches in this
3117/// crate), and when most of the result will be filtered away or completely
3118/// unused, it makes sense to skip/delay decoding.
3119#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
3120pub struct SpendableNoteUndecoded {
3121    // Need to keep this in sync with `tbs::Signature`, but there's a test
3122    // verifying they serialize and decode the same.
3123    #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
3124    pub signature: [u8; 48],
3125    pub spend_key: Keypair,
3126}
3127
3128impl fmt::Display for SpendableNoteUndecoded {
3129    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3130        write!(f, "{}", self.nonce().fmt_short())
3131    }
3132}
3133
3134impl fmt::Debug for SpendableNoteUndecoded {
3135    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3136        f.debug_struct("SpendableNote")
3137            .field("nonce", &self.nonce())
3138            .field("signature", &"[raw]")
3139            .field("spend_key", &self.spend_key)
3140            .finish()
3141    }
3142}
3143
3144impl SpendableNoteUndecoded {
3145    fn nonce(&self) -> Nonce {
3146        Nonce(self.spend_key.public_key())
3147    }
3148
3149    pub fn decode(self) -> Result<SpendableNote, DecodeError> {
3150        Ok(SpendableNote {
3151            signature: Decodable::consensus_decode_partial_from_finite_reader(
3152                &mut self.signature.as_slice(),
3153                &ModuleRegistry::default(),
3154            )?,
3155            spend_key: self.spend_key,
3156        })
3157    }
3158}
3159
3160/// An index used to deterministically derive [`Note`]s
3161///
3162/// We allow converting it to u64 and incrementing it, but
3163/// messing with it should be somewhat restricted to prevent
3164/// silly errors.
3165#[derive(
3166    Copy,
3167    Clone,
3168    Debug,
3169    Serialize,
3170    Deserialize,
3171    PartialEq,
3172    Eq,
3173    Encodable,
3174    Decodable,
3175    Default,
3176    PartialOrd,
3177    Ord,
3178)]
3179pub struct NoteIndex(u64);
3180
3181impl NoteIndex {
3182    pub fn next(self) -> Self {
3183        Self(self.0 + 1)
3184    }
3185
3186    fn prev(self) -> Option<Self> {
3187        self.0.checked_sub(0).map(Self)
3188    }
3189
3190    pub fn as_u64(self) -> u64 {
3191        self.0
3192    }
3193
3194    // Private. If it turns out it is useful outside,
3195    // we can relax and convert to `From<u64>`
3196    // Actually used in tests RN, so cargo complains in non-test builds.
3197    #[allow(unused)]
3198    pub fn from_u64(v: u64) -> Self {
3199        Self(v)
3200    }
3201
3202    pub fn advance(&mut self) {
3203        *self = self.next();
3204    }
3205}
3206
3207impl std::fmt::Display for NoteIndex {
3208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3209        self.0.fmt(f)
3210    }
3211}
3212
3213struct OOBSpendTag;
3214
3215impl sha256t::Tag for OOBSpendTag {
3216    fn engine() -> sha256::HashEngine {
3217        let mut engine = sha256::HashEngine::default();
3218        engine.input(b"oob-spend");
3219        engine
3220    }
3221}
3222
3223struct OOBReissueTag;
3224
3225impl sha256t::Tag for OOBReissueTag {
3226    fn engine() -> sha256::HashEngine {
3227        let mut engine = sha256::HashEngine::default();
3228        engine.input(b"oob-reissue");
3229        engine
3230    }
3231}
3232
3233/// Determines the denominations to use when representing an amount
3234///
3235/// Algorithm tries to leave the user with a target number of
3236/// `denomination_sets` starting at the lowest denomination.  `self`
3237/// gives the denominations that the user already has.
3238pub fn represent_amount<K>(
3239    amount: Amount,
3240    current_denominations: &TieredCounts,
3241    tiers: &Tiered<K>,
3242    denomination_sets: u16,
3243    fee_consensus: &FeeConsensus,
3244) -> TieredCounts {
3245    let mut remaining_amount = amount;
3246    let mut denominations = TieredCounts::default();
3247
3248    // try to hit the target `denomination_sets`
3249    for tier in tiers.tiers() {
3250        let notes = current_denominations.get(*tier);
3251        let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
3252        let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
3253
3254        let add_notes = min(possible_notes, missing_notes);
3255        denominations.inc(*tier, add_notes as usize);
3256        remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
3257    }
3258
3259    // if there is a remaining amount, add denominations with a greedy algorithm
3260    for tier in tiers.tiers().rev() {
3261        let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
3262        remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
3263        denominations.inc(*tier, res as usize);
3264    }
3265
3266    let represented: u64 = denominations
3267        .iter()
3268        .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
3269        .sum();
3270
3271    assert!(represented <= amount.msats);
3272    assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
3273
3274    denominations
3275}
3276
3277pub(crate) fn create_bundle_for_inputs(
3278    inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
3279    operation_id: OperationId,
3280) -> ClientInputBundle<MintInput, MintClientStateMachines> {
3281    let mut inputs = Vec::new();
3282    let mut input_states = Vec::new();
3283
3284    for (input, spendable_note) in inputs_and_notes {
3285        input_states.push((input.amounts.clone(), spendable_note));
3286        inputs.push(input);
3287    }
3288
3289    let input_sm = Arc::new(move |out_point_range: OutPointRange| {
3290        debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
3291
3292        vec![MintClientStateMachines::Input(MintInputStateMachine {
3293            common: MintInputCommon {
3294                operation_id,
3295                out_point_range,
3296            },
3297            state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
3298                notes: input_states
3299                    .iter()
3300                    .map(|(amounts, note)| (amounts.expect_only_bitcoin(), *note))
3301                    .collect(),
3302            }),
3303        })]
3304    });
3305
3306    ClientInputBundle::new(
3307        inputs,
3308        vec![ClientInputSM {
3309            state_machines: input_sm,
3310        }],
3311    )
3312}
3313
3314#[cfg(test)]
3315mod tests {
3316    use std::fmt::Display;
3317    use std::str::FromStr;
3318
3319    use assert_matches::assert_matches;
3320    use bitcoin_hashes::Hash;
3321    use fedimint_core::base32::FEDIMINT_PREFIX;
3322    use fedimint_core::config::FederationId;
3323    use fedimint_core::encoding::{Decodable, DecodeError};
3324    use fedimint_core::invite_code::InviteCode;
3325    use fedimint_core::module::registry::ModuleRegistry;
3326    use fedimint_core::{
3327        Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
3328    };
3329    use fedimint_mint_common::config::FeeConsensus;
3330    use itertools::Itertools;
3331    use serde_json::json;
3332
3333    use crate::error::{AwaitOutputFinalizedError, OOBNotesParseError, SelectNotesError};
3334    use crate::{
3335        MintOperationMetaVariant, NotesSelector, OOBNotes, OOBNotesPart,
3336        SelectNotesWithExactAmount, SpendableNote, SpendableNoteUndecoded, represent_amount,
3337        select_notes_from_stream,
3338    };
3339
3340    #[test]
3341    fn represent_amount_targets_denomination_sets() {
3342        fn tiers(tiers: Vec<u64>) -> Tiered<()> {
3343            tiers
3344                .into_iter()
3345                .map(|tier| (Amount::from_sats(tier), ()))
3346                .collect()
3347        }
3348
3349        fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
3350            TieredCounts::from_iter(denominations)
3351        }
3352
3353        let starting = notes(vec![
3354            (Amount::from_sats(1), 1),
3355            (Amount::from_sats(2), 3),
3356            (Amount::from_sats(3), 2),
3357        ])
3358        .summary();
3359        let tiers = tiers(vec![1, 2, 3, 4]);
3360
3361        // target 3 tiers will fill out the 1 and 3 denominations
3362        assert_eq!(
3363            represent_amount(
3364                Amount::from_sats(6),
3365                &starting,
3366                &tiers,
3367                3,
3368                &FeeConsensus::zero()
3369            ),
3370            denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
3371        );
3372
3373        // target 2 tiers will fill out the 1 and 4 denominations
3374        assert_eq!(
3375            represent_amount(
3376                Amount::from_sats(6),
3377                &starting,
3378                &tiers,
3379                2,
3380                &FeeConsensus::zero()
3381            ),
3382            denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
3383        );
3384    }
3385
3386    #[test_log::test(tokio::test)]
3387    async fn select_notes_avg_test() {
3388        let max_amount = Amount::from_sats(1_000_000);
3389        let tiers = Tiered::gen_denominations(2, max_amount);
3390        let tiered = represent_amount::<()>(
3391            max_amount,
3392            &TieredCounts::default(),
3393            &tiers,
3394            3,
3395            &FeeConsensus::zero(),
3396        );
3397
3398        let mut total_notes = 0;
3399        for multiplier in 1..100 {
3400            let stream = reverse_sorted_note_stream(tiered.iter().collect());
3401            let select = select_notes_from_stream(
3402                stream,
3403                Amount::from_sats(multiplier * 1000),
3404                FeeConsensus::zero(),
3405            )
3406            .await;
3407            total_notes += select.unwrap().into_iter_items().count();
3408        }
3409        assert_eq!(total_notes / 100, 10);
3410    }
3411
3412    #[test_log::test(tokio::test)]
3413    async fn select_notes_returns_exact_amount_with_minimum_notes() {
3414        let f = || {
3415            reverse_sorted_note_stream(vec![
3416                (Amount::from_sats(1), 10),
3417                (Amount::from_sats(5), 10),
3418                (Amount::from_sats(20), 10),
3419            ])
3420        };
3421        assert_eq!(
3422            select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
3423                .await
3424                .unwrap(),
3425            notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
3426        );
3427        assert_eq!(
3428            select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
3429                .await
3430                .unwrap(),
3431            notes(vec![(Amount::from_sats(20), 1)])
3432        );
3433    }
3434
3435    #[test_log::test(tokio::test)]
3436    async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
3437        let stream = reverse_sorted_note_stream(vec![
3438            (Amount::from_sats(1), 1),
3439            (Amount::from_sats(5), 5),
3440            (Amount::from_sats(20), 5),
3441        ]);
3442        assert_eq!(
3443            select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
3444                .await
3445                .unwrap(),
3446            notes(vec![(Amount::from_sats(5), 2)])
3447        );
3448    }
3449
3450    #[test_log::test(tokio::test)]
3451    async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
3452        let stream = reverse_sorted_note_stream(vec![
3453            (Amount::from_sats(1), 3),
3454            (Amount::from_sats(5), 3),
3455            (Amount::from_sats(20), 2),
3456        ]);
3457        assert_eq!(
3458            select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
3459                .await
3460                .unwrap(),
3461            notes(vec![(Amount::from_sats(20), 2)])
3462        );
3463    }
3464
3465    #[test_log::test(tokio::test)]
3466    async fn select_notes_returns_error_if_amount_is_too_large() {
3467        let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
3468        let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
3469            .await
3470            .unwrap_err();
3471        assert_eq!(error.total_amount, Amount::from_sats(10));
3472    }
3473
3474    #[test_log::test(tokio::test)]
3475    async fn selecting_an_unrepresentable_exact_amount_reports_what_was_selected() {
3476        let notes = reverse_sorted_note_stream(vec![(Amount::from_msats(4), 1)]);
3477
3478        let err = SelectNotesWithExactAmount
3479            .select_notes(notes, Amount::from_msats(3), FeeConsensus::zero())
3480            .await
3481            .expect_err("Three msats cannot be made from a single four-msat note");
3482
3483        assert_matches!(
3484            err,
3485            SelectNotesError::NoExactAmount { requested, selected }
3486                if requested == Amount::from_msats(3) && selected == Amount::from_msats(4)
3487        );
3488    }
3489
3490    fn reverse_sorted_note_stream(
3491        notes: Vec<(Amount, usize)>,
3492    ) -> impl futures::Stream<Item = (Amount, String)> {
3493        futures::stream::iter(
3494            notes
3495                .into_iter()
3496                // We are creating `number` dummy notes of `amount` value
3497                .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
3498                .sorted()
3499                .rev(),
3500        )
3501    }
3502
3503    fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
3504        notes
3505            .into_iter()
3506            .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
3507            .collect()
3508    }
3509
3510    #[test]
3511    fn decoding_empty_oob_notes_fails() {
3512        let empty_oob_notes =
3513            OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
3514        let oob_notes_string = empty_oob_notes.to_string();
3515
3516        let res = oob_notes_string.parse::<OOBNotes>();
3517
3518        assert!(res.is_err(), "An empty OOB notes string should not parse");
3519    }
3520
3521    fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
3522    where
3523        T: FromStr + Display + crate::Encodable + crate::Decodable,
3524        <T as FromStr>::Err: std::fmt::Debug,
3525        F: Fn(T),
3526    {
3527        let data_parsed = data.to_string().parse().expect("Deserialization failed");
3528
3529        assertions(data_parsed);
3530
3531        let data_parsed = crate::base32::encode_prefixed(FEDIMINT_PREFIX, &data)
3532            .parse()
3533            .expect("Deserialization failed");
3534
3535        assertions(data_parsed);
3536
3537        assertions(data);
3538    }
3539
3540    #[test]
3541    fn notes_encode_decode() {
3542        let federation_id_1 =
3543            FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
3544        let federation_id_prefix_1 = federation_id_1.to_prefix();
3545        let federation_id_2 =
3546            FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
3547        let federation_id_prefix_2 = federation_id_2.to_prefix();
3548
3549        let notes = vec![(
3550            Amount::from_sats(1),
3551            SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
3552        )]
3553        .into_iter()
3554        .collect::<TieredMulti<_>>();
3555
3556        // Can decode inviteless notes
3557        let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
3558        test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
3559            assert_eq!(oob_notes.notes(), &notes);
3560            assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3561            assert_eq!(oob_notes.federation_invite(), None);
3562        });
3563
3564        // Can decode notes with invite
3565        let invite = InviteCode::new(
3566            "wss://foo.bar".parse().unwrap(),
3567            PeerId::from(0),
3568            federation_id_1,
3569            None,
3570        );
3571        let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
3572        test_roundtrip_serialize_str(notes_invite, |oob_notes| {
3573            assert_eq!(oob_notes.notes(), &notes);
3574            assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3575            assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
3576        });
3577
3578        // Can decode notes without federation id prefix, so we can optionally remove it
3579        // in the future
3580        let notes_no_prefix = OOBNotes(vec![
3581            OOBNotesPart::Notes(notes.clone()),
3582            OOBNotesPart::Invite {
3583                peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
3584                federation_id: federation_id_1,
3585            },
3586        ]);
3587        test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
3588            assert_eq!(oob_notes.notes(), &notes);
3589            assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3590        });
3591
3592        // Rejects notes with inconsistent federation id
3593        let notes_inconsistent = OOBNotes(vec![
3594            OOBNotesPart::Notes(notes),
3595            OOBNotesPart::Invite {
3596                peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
3597                federation_id: federation_id_1,
3598            },
3599            OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
3600        ]);
3601        let notes_inconsistent_str = notes_inconsistent.to_string();
3602        assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
3603    }
3604
3605    #[test]
3606    fn spendable_note_undecoded_sanity() {
3607        // TODO: add more hex dumps to the loop
3608        #[allow(clippy::single_element_loop)]
3609        for note_hex in [
3610            "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
3611        ] {
3612            let note =
3613                SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
3614            let note_undecoded =
3615                SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
3616                    .unwrap()
3617                    .decode()
3618                    .unwrap();
3619            assert_eq!(note, note_undecoded,);
3620            assert_eq!(
3621                serde_json::to_string(&note).unwrap(),
3622                serde_json::to_string(&note_undecoded).unwrap(),
3623            );
3624        }
3625    }
3626
3627    #[test]
3628    fn reissuance_meta_compatibility_02_03() {
3629        let dummy_outpoint = OutPoint {
3630            txid: TransactionId::all_zeros(),
3631            out_idx: 0,
3632        };
3633
3634        let old_meta_json = json!({
3635            "reissuance": {
3636                "out_point": dummy_outpoint
3637            }
3638        });
3639
3640        let old_meta: MintOperationMetaVariant =
3641            serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
3642        assert_eq!(
3643            old_meta,
3644            MintOperationMetaVariant::Reissuance {
3645                legacy_out_point: Some(dummy_outpoint),
3646                txid: None,
3647                out_point_indices: vec![],
3648            }
3649        );
3650
3651        let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
3652            legacy_out_point: None,
3653            txid: Some(dummy_outpoint.txid),
3654            out_point_indices: vec![0],
3655        })
3656        .expect("serializing always works");
3657        assert_eq!(
3658            new_meta_json,
3659            json!({
3660                "reissuance": {
3661                    "txid": dummy_outpoint.txid,
3662                    "out_point_indices": [dummy_outpoint.out_idx],
3663                }
3664            })
3665        );
3666    }
3667
3668    #[test]
3669    fn spend_oob_meta_no_timeout_defaults_to_false() {
3670        let notes = vec![(
3671            Amount::from_sats(1),
3672            SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
3673        )]
3674        .into_iter()
3675        .collect::<TieredMulti<_>>();
3676        let oob_notes = OOBNotes::new(FederationId::dummy().to_prefix(), notes);
3677        let mut old_meta_json = serde_json::to_value(MintOperationMetaVariant::SpendOOB {
3678            requested_amount: Amount::from_sats(42),
3679            oob_notes: oob_notes.clone(),
3680            no_timeout: false,
3681        })
3682        .expect("serializing always works");
3683        old_meta_json
3684            .get_mut("spend_o_o_b")
3685            .expect("spend OOB variant should serialize as spend_o_o_b")
3686            .as_object_mut()
3687            .expect("spend OOB variant should serialize to an object")
3688            .remove("no_timeout");
3689        assert_eq!(
3690            old_meta_json,
3691            json!({
3692                "spend_o_o_b": {
3693                    "requested_amount": Amount::from_sats(42),
3694                    "oob_notes": oob_notes.clone(),
3695                }
3696            })
3697        );
3698
3699        let old_meta: MintOperationMetaVariant =
3700            serde_json::from_value(old_meta_json).expect("parsing old spend OOB meta failed");
3701        assert_eq!(
3702            old_meta,
3703            MintOperationMetaVariant::SpendOOB {
3704                requested_amount: Amount::from_sats(42),
3705                oob_notes,
3706                no_timeout: false,
3707            }
3708        );
3709    }
3710
3711    #[test]
3712    fn parsing_a_non_encoded_string_names_the_encoding() {
3713        let err = OOBNotes::from_str("not base32 or base64 $$$")
3714            .expect_err("A string that is neither base32 nor base64 cannot be notes");
3715
3716        assert_matches!(err, OOBNotesParseError::Encoding);
3717    }
3718
3719    #[test]
3720    fn the_parse_error_prints_its_cause_because_clap_only_shows_display() {
3721        let err = OOBNotesParseError::Decode(DecodeError::from_str("no notes here"));
3722
3723        assert!(
3724            err.to_string().contains("no notes here"),
3725            "clap and serde print only Display, so the cause has to be in the message"
3726        );
3727    }
3728
3729    #[test]
3730    fn a_finalization_failure_carries_the_state_machines_reason() {
3731        use fedimint_core::util::FmtCompact as _;
3732
3733        let err = AwaitOutputFinalizedError::Failed {
3734            reason: "guardian refused the blind signature".to_owned(),
3735        };
3736
3737        assert!(
3738            err.fmt_compact().to_string().contains("guardian refused"),
3739            "the reason the state machine recorded has to survive into the Failed state"
3740        );
3741    }
3742
3743    #[test]
3744    fn a_share_from_a_peer_we_have_no_key_for_names_the_peer() {
3745        use std::collections::BTreeMap;
3746
3747        use bls12_381::G1Affine;
3748        use fedimint_api_client::api::SerdeOutputOutcome;
3749        use fedimint_core::core::DynOutputOutcome;
3750        use fedimint_core::module::CommonModuleInit;
3751        use fedimint_mint_common::{MintCommonInit, MintOutputOutcome};
3752        use tbs::{BlindedMessage, BlindedSignatureShare};
3753
3754        use crate::error::VerifyBlindShareError;
3755        use crate::output::verify_blind_share;
3756
3757        let peer = PeerId::from(7);
3758        let decoder = MintCommonInit::decoder();
3759        let outcome = MintOutputOutcome::new_v0(BlindedSignatureShare(G1Affine::identity()));
3760        let serde_outcome = SerdeOutputOutcome::from(&DynOutputOutcome::from_typed(0, outcome));
3761
3762        let err = verify_blind_share(
3763            peer,
3764            &serde_outcome,
3765            Amount::from_sats(1),
3766            BlindedMessage(G1Affine::identity()),
3767            &decoder,
3768            &BTreeMap::new(),
3769        )
3770        .expect_err("no peer keys are known, so no key can be found for the peer");
3771
3772        assert_matches!(err, VerifyBlindShareError::UnknownPeer { peer: p } if p == peer);
3773    }
3774}