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