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