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