Skip to main content

fedimint_mint_client/
lib.rs

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