fedimint_mint_client/
lib.rs

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