Skip to main content

fedimint_mint_client/
lib.rs

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