Skip to main content

fedimint_mint_client/
lib.rs

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