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