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    /// Wait for the e-cash notes to be retrieved. If this is not possible
1161    /// because another terminal state was reached an error describing the
1162    /// failure is returned.
1163    pub async fn await_output_finalized(
1164        &self,
1165        operation_id: OperationId,
1166        out_point: OutPoint,
1167    ) -> anyhow::Result<()> {
1168        let stream = self
1169            .notifier
1170            .subscribe(operation_id)
1171            .await
1172            .filter_map(|state| async {
1173                let MintClientStateMachines::Output(state) = state else {
1174                    return None;
1175                };
1176
1177                if state.common.txid() != out_point.txid
1178                    || !state
1179                        .common
1180                        .out_point_range
1181                        .out_idx_iter()
1182                        .contains(&out_point.out_idx)
1183                {
1184                    return None;
1185                }
1186
1187                match state.state {
1188                    MintOutputStates::Succeeded(_) => Some(Ok(())),
1189                    MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
1190                    MintOutputStates::Failed(failed) => Some(Err(anyhow!(
1191                        "Failed to finalize transaction: {}",
1192                        failed.error
1193                    ))),
1194                    MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1195                }
1196            });
1197        pin_mut!(stream);
1198
1199        stream.next_or_pending().await
1200    }
1201
1202    /// Provisional implementation of note consolidation
1203    ///
1204    /// When a certain denomination crosses the threshold of notes allowed,
1205    /// spend some chunk of them as inputs.
1206    ///
1207    /// Return notes and the sume of their amount.
1208    pub async fn consolidate_notes(
1209        &self,
1210        dbtx: &mut DatabaseTransaction<'_>,
1211    ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1212        /// At how many notes of the same denomination should we try to
1213        /// consolidate
1214        const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1215        /// Number of notes per tier to leave after threshold was crossed
1216        const MIN_NOTES_PER_TIER: usize = 4;
1217        /// Maximum number of notes to consolidate per one tx,
1218        /// to limit the size of a transaction produced.
1219        const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1220        // it's fine, it's just documentation
1221        #[allow(clippy::assertions_on_constants)]
1222        {
1223            assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1224        }
1225
1226        let counts = self.get_note_counts_by_denomination(dbtx).await;
1227
1228        let should_consolidate = counts
1229            .iter()
1230            .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1231
1232        if !should_consolidate {
1233            return Ok(vec![]);
1234        }
1235
1236        let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1237
1238        let excessive_counts: TieredCounts = counts
1239            .iter()
1240            .map(|(amount, count)| {
1241                let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1242
1243                max_count -= take;
1244                (amount, take)
1245            })
1246            .collect();
1247
1248        let (selected_notes, unavailable) = self
1249            .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1250            .await;
1251
1252        debug_assert!(
1253            unavailable.is_empty(),
1254            "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1255        );
1256
1257        if !selected_notes.is_empty() {
1258            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");
1259        }
1260
1261        let mut selected_notes_decoded = vec![];
1262        for (amount, note) in selected_notes.iter_items() {
1263            let spendable_note_decoded = note.decode()?;
1264            debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1265            Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1266                .await;
1267            selected_notes_decoded.push((amount, spendable_note_decoded));
1268        }
1269
1270        let sender = self.balance_update_sender.clone();
1271        dbtx.on_commit(move || sender.send_replace(()));
1272
1273        self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1274    }
1275
1276    /// Create a mint input from external, potentially untrusted notes
1277    #[allow(clippy::type_complexity)]
1278    pub fn create_input_from_notes(
1279        &self,
1280        notes: TieredMulti<SpendableNote>,
1281    ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1282        let mut inputs_and_notes = Vec::new();
1283
1284        for (amount, spendable_note) in notes.into_iter_items() {
1285            let key = self
1286                .cfg
1287                .tbs_pks
1288                .get(amount)
1289                .ok_or(anyhow!("Invalid amount tier: {amount}"))?;
1290
1291            let note = spendable_note.note();
1292
1293            if !note.verify(*key) {
1294                bail!("Invalid note");
1295            }
1296
1297            inputs_and_notes.push((
1298                ClientInput {
1299                    input: MintInput::new_v0(amount, note),
1300                    keys: vec![spendable_note.spend_key],
1301                    amounts: Amounts::new_bitcoin(amount),
1302                },
1303                spendable_note,
1304            ));
1305        }
1306
1307        Ok(inputs_and_notes)
1308    }
1309
1310    async fn spend_notes_oob(
1311        &self,
1312        dbtx: &mut DatabaseTransaction<'_>,
1313        notes_selector: &impl NotesSelector,
1314        amount: Amount,
1315        try_cancel_after: Duration,
1316    ) -> anyhow::Result<(
1317        OperationId,
1318        Vec<MintClientStateMachines>,
1319        TieredMulti<SpendableNote>,
1320    )> {
1321        ensure!(
1322            amount > Amount::ZERO,
1323            "zero-amount out-of-band spends are not supported"
1324        );
1325
1326        let selected_notes =
1327            Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1328
1329        let operation_id = spendable_notes_to_operation_id(&selected_notes);
1330
1331        for (amount, note) in selected_notes.iter_items() {
1332            debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1333            MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1334        }
1335
1336        let sender = self.balance_update_sender.clone();
1337        dbtx.on_commit(move || sender.send_replace(()));
1338
1339        let state_machines = vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1340            operation_id,
1341            state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1342                spendable_notes: selected_notes.clone().into_iter_items().collect(),
1343                timeout: fedimint_core::time::now() + try_cancel_after,
1344            }),
1345        })];
1346
1347        Ok((operation_id, state_machines, selected_notes))
1348    }
1349
1350    pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1351        Box::pin(
1352            self.notifier
1353                .subscribe(operation_id)
1354                .await
1355                .filter_map(|state| async {
1356                    let MintClientStateMachines::OOB(state) = state else {
1357                        return None;
1358                    };
1359
1360                    match state.state {
1361                        MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1362                            user_triggered: false,
1363                            transaction_ids: vec![refund.refund_txid],
1364                        }),
1365                        MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1366                            user_triggered: true,
1367                            transaction_ids: vec![refund.refund_txid],
1368                        }),
1369                        MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1370                            user_triggered: true,
1371                            transaction_ids: vec![refund.refund_txid],
1372                        }),
1373                        MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1374                    }
1375                }),
1376        )
1377        .next_or_pending()
1378        .await
1379    }
1380
1381    /// Select notes with `requested_amount` using `notes_selector`.
1382    async fn select_notes(
1383        dbtx: &mut DatabaseTransaction<'_>,
1384        notes_selector: &impl NotesSelector,
1385        requested_amount: Amount,
1386        fee_consensus: FeeConsensus,
1387    ) -> anyhow::Result<TieredMulti<SpendableNote>> {
1388        let note_stream = dbtx
1389            .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1390            .await
1391            .map(|(key, note)| (key.amount, note));
1392
1393        notes_selector
1394            .select_notes(note_stream, requested_amount, fee_consensus)
1395            .await?
1396            .into_iter_items()
1397            .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1398            .collect::<anyhow::Result<TieredMulti<_>>>()
1399    }
1400
1401    async fn get_all_spendable_notes(
1402        dbtx: &mut DatabaseTransaction<'_>,
1403    ) -> TieredMulti<SpendableNoteUndecoded> {
1404        (dbtx
1405            .find_by_prefix(&NoteKeyPrefix)
1406            .await
1407            .map(|(key, note)| (key.amount, note))
1408            .collect::<Vec<_>>()
1409            .await)
1410            .into_iter()
1411            .collect()
1412    }
1413
1414    async fn get_next_note_index(
1415        &self,
1416        dbtx: &mut DatabaseTransaction<'_>,
1417        amount: Amount,
1418    ) -> NoteIndex {
1419        NoteIndex(
1420            dbtx.get_value(&NextECashNoteIndexKey(amount))
1421                .await
1422                .unwrap_or(0),
1423        )
1424    }
1425
1426    /// Derive the note `DerivableSecret` from the Mint's `secret` the `amount`
1427    /// tier and `note_idx`
1428    ///
1429    /// Static to help re-use in other places, that don't have a whole [`Self`]
1430    /// available
1431    ///
1432    /// # E-Cash Note Creation
1433    ///
1434    /// When creating an e-cash note, the `MintClientModule` first derives the
1435    /// blinding and spend keys from the `DerivableSecret`. It then creates a
1436    /// `NoteIssuanceRequest` containing the blinded spend key and sends it to
1437    /// the mint server. The mint server signs the blinded spend key and
1438    /// returns it to the client. The client can then unblind the signed
1439    /// spend key to obtain the e-cash note, which can be spent using the
1440    /// spend key.
1441    pub fn new_note_secret_static(
1442        secret: &DerivableSecret,
1443        amount: Amount,
1444        note_idx: NoteIndex,
1445    ) -> DerivableSecret {
1446        assert_eq!(secret.level(), 2);
1447        debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1448        secret
1449            .child_key(MINT_E_CASH_TYPE_CHILD_ID) // TODO: cache
1450            .child_key(ChildId(note_idx.as_u64()))
1451            .child_key(ChildId(amount.msats))
1452    }
1453
1454    /// We always keep track of an incrementing index in the database and use
1455    /// it as part of the derivation path for the note secret. This ensures that
1456    /// we never reuse the same note secret twice.
1457    async fn new_note_secret(
1458        &self,
1459        amount: Amount,
1460        dbtx: &mut DatabaseTransaction<'_>,
1461    ) -> DerivableSecret {
1462        let new_idx = self.get_next_note_index(dbtx, amount).await;
1463        dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1464            .await;
1465        Self::new_note_secret_static(&self.secret, amount, new_idx)
1466    }
1467
1468    pub async fn new_ecash_note(
1469        &self,
1470        amount: Amount,
1471        dbtx: &mut DatabaseTransaction<'_>,
1472    ) -> (NoteIssuanceRequest, BlindNonce) {
1473        let secret = self.new_note_secret(amount, dbtx).await;
1474        NoteIssuanceRequest::new(&self.secp, &secret)
1475    }
1476
1477    /// Try to reissue e-cash notes received from a third party to receive them
1478    /// in our wallet. The progress and outcome can be observed using
1479    /// [`MintClientModule::subscribe_reissue_external_notes`].
1480    /// Can return error of type [`ReissueExternalNotesError`]
1481    pub async fn reissue_external_notes<M: Serialize + Send>(
1482        &self,
1483        oob_notes: OOBNotes,
1484        extra_meta: M,
1485    ) -> anyhow::Result<OperationId> {
1486        let notes = oob_notes.notes().clone();
1487        let federation_id_prefix = oob_notes.federation_id_prefix();
1488
1489        ensure!(
1490            notes.total_amount() > Amount::ZERO,
1491            "Reissuing zero-amount e-cash isn't supported"
1492        );
1493
1494        if federation_id_prefix != self.federation_id.to_prefix() {
1495            bail!(ReissueExternalNotesError::WrongFederationId);
1496        }
1497
1498        let operation_id = OperationId(
1499            notes
1500                .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
1501                .to_byte_array(),
1502        );
1503
1504        let amount = notes.total_amount();
1505        let mint_inputs = self.create_input_from_notes(notes)?;
1506
1507        let tx = TransactionBuilder::new().with_inputs(
1508            self.client_ctx
1509                .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
1510        );
1511
1512        let extra_meta = serde_json::to_value(extra_meta)
1513            .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
1514        let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
1515            variant: MintOperationMetaVariant::Reissuance {
1516                legacy_out_point: None,
1517                txid: Some(change_range.txid()),
1518                out_point_indices: change_range
1519                    .into_iter()
1520                    .map(|out_point| out_point.out_idx)
1521                    .collect(),
1522            },
1523            amount,
1524            extra_meta: extra_meta.clone(),
1525        };
1526
1527        self.client_ctx
1528            .finalize_and_submit_transaction(
1529                operation_id,
1530                MintCommonInit::KIND.as_str(),
1531                operation_meta_gen,
1532                tx,
1533            )
1534            .await
1535            .context(ReissueExternalNotesError::AlreadyReissued)?;
1536
1537        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1538
1539        self.client_ctx
1540            .log_event(&mut dbtx, OOBNotesReissued { amount })
1541            .await;
1542
1543        self.client_ctx
1544            .log_event(
1545                &mut dbtx,
1546                ReceivePaymentEvent {
1547                    operation_id,
1548                    amount,
1549                },
1550            )
1551            .await;
1552
1553        dbtx.commit_tx().await;
1554
1555        Ok(operation_id)
1556    }
1557
1558    /// Subscribe to updates on the progress of a reissue operation started with
1559    /// [`MintClientModule::reissue_external_notes`].
1560    pub async fn subscribe_reissue_external_notes(
1561        &self,
1562        operation_id: OperationId,
1563    ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
1564        let operation = self.mint_operation(operation_id).await?;
1565        let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
1566            MintOperationMetaVariant::Reissuance {
1567                legacy_out_point,
1568                txid,
1569                out_point_indices,
1570            } => {
1571                // Either txid or legacy_out_point will be present, so we should always
1572                // have a source for the txid
1573                let txid = txid
1574                    .or(legacy_out_point.map(|out_point| out_point.txid))
1575                    .context("Empty reissuance not permitted, this should never happen")?;
1576
1577                let out_points = out_point_indices
1578                    .into_iter()
1579                    .map(|out_idx| OutPoint { txid, out_idx })
1580                    .chain(legacy_out_point)
1581                    .collect::<Vec<_>>();
1582
1583                (txid, out_points)
1584            }
1585            MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"),
1586            MintOperationMetaVariant::Recovery { .. } => unimplemented!(),
1587        };
1588
1589        let client_ctx = self.client_ctx.clone();
1590
1591        Ok(self.client_ctx.outcome_or_updates(operation, operation_id, move || {
1592            stream! {
1593                yield ReissueExternalNotesState::Created;
1594
1595                match client_ctx
1596                    .transaction_updates(operation_id)
1597                    .await
1598                    .await_tx_accepted(txid)
1599                    .await
1600                {
1601                    Ok(()) => {
1602                        yield ReissueExternalNotesState::Issuing;
1603                    }
1604                    Err(e) => {
1605                        yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
1606                        return;
1607                    }
1608                }
1609
1610                for out_point in out_points {
1611                    if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
1612                        yield ReissueExternalNotesState::Failed(e.to_string());
1613                        return;
1614                    }
1615                }
1616                yield ReissueExternalNotesState::Done;
1617            }}
1618        ))
1619    }
1620
1621    /// Fetches and removes notes of *at least* amount `min_amount` from the
1622    /// wallet to be sent to the recipient out of band. These spends can be
1623    /// canceled by calling [`MintClientModule::try_cancel_spend_notes`] as long
1624    /// as the recipient hasn't reissued the e-cash notes themselves yet.
1625    ///
1626    /// The client will also automatically attempt to cancel the operation after
1627    /// `try_cancel_after` time has passed. This is a safety mechanism to avoid
1628    /// users forgetting about failed out-of-band transactions. The timeout
1629    /// should be chosen such that the recipient (who is potentially offline at
1630    /// the time of receiving the e-cash notes) had a reasonable timeframe to
1631    /// come online and reissue the notes themselves.
1632    #[deprecated(
1633        since = "0.5.0",
1634        note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
1635    )]
1636    pub async fn spend_notes<M: Serialize + Send>(
1637        &self,
1638        min_amount: Amount,
1639        try_cancel_after: Duration,
1640        include_invite: bool,
1641        extra_meta: M,
1642    ) -> anyhow::Result<(OperationId, OOBNotes)> {
1643        self.spend_notes_with_selector(
1644            &SelectNotesWithAtleastAmount,
1645            min_amount,
1646            try_cancel_after,
1647            include_invite,
1648            extra_meta,
1649        )
1650        .await
1651    }
1652
1653    /// Fetches and removes notes from the wallet to be sent to the recipient
1654    /// out of band. The note selection algorithm is determined by
1655    /// `note_selector`. See the [`NotesSelector`] trait for available
1656    /// implementations.
1657    ///
1658    /// These spends can be canceled by calling
1659    /// [`MintClientModule::try_cancel_spend_notes`] as long
1660    /// as the recipient hasn't reissued the e-cash notes themselves yet.
1661    ///
1662    /// The client will also automatically attempt to cancel the operation after
1663    /// `try_cancel_after` time has passed. This is a safety mechanism to avoid
1664    /// users forgetting about failed out-of-band transactions. The timeout
1665    /// should be chosen such that the recipient (who is potentially offline at
1666    /// the time of receiving the e-cash notes) had a reasonable timeframe to
1667    /// come online and reissue the notes themselves.
1668    pub async fn spend_notes_with_selector<M: Serialize + Send>(
1669        &self,
1670        notes_selector: &impl NotesSelector,
1671        requested_amount: Amount,
1672        try_cancel_after: Duration,
1673        include_invite: bool,
1674        extra_meta: M,
1675    ) -> anyhow::Result<(OperationId, OOBNotes)> {
1676        let federation_id_prefix = self.federation_id.to_prefix();
1677        let extra_meta = serde_json::to_value(extra_meta)
1678            .expect("MintClientModule::spend_notes extra_meta is serializable");
1679
1680        self.client_ctx
1681            .module_db()
1682            .autocommit(
1683                |dbtx, _| {
1684                    let extra_meta = extra_meta.clone();
1685                    Box::pin(async {
1686                        let (operation_id, states, notes) = self
1687                            .spend_notes_oob(
1688                                dbtx,
1689                                notes_selector,
1690                                requested_amount,
1691                                try_cancel_after,
1692                            )
1693                            .await?;
1694
1695                        let oob_notes = if include_invite {
1696                            OOBNotes::new_with_invite(
1697                                notes,
1698                                &self.client_ctx.get_invite_code().await,
1699                            )
1700                        } else {
1701                            OOBNotes::new(federation_id_prefix, notes)
1702                        };
1703
1704                        self.client_ctx
1705                            .add_state_machines_dbtx(
1706                                dbtx,
1707                                self.client_ctx.map_dyn(states).collect(),
1708                            )
1709                            .await?;
1710                        self.client_ctx
1711                            .add_operation_log_entry_dbtx(
1712                                dbtx,
1713                                operation_id,
1714                                MintCommonInit::KIND.as_str(),
1715                                MintOperationMeta {
1716                                    variant: MintOperationMetaVariant::SpendOOB {
1717                                        requested_amount,
1718                                        oob_notes: oob_notes.clone(),
1719                                    },
1720                                    amount: oob_notes.total_amount(),
1721                                    extra_meta,
1722                                },
1723                            )
1724                            .await;
1725                        self.client_ctx
1726                            .log_event(
1727                                dbtx,
1728                                OOBNotesSpent {
1729                                    requested_amount,
1730                                    spent_amount: oob_notes.total_amount(),
1731                                    timeout: try_cancel_after,
1732                                    include_invite,
1733                                },
1734                            )
1735                            .await;
1736
1737                        self.client_ctx
1738                            .log_event(
1739                                dbtx,
1740                                SendPaymentEvent {
1741                                    operation_id,
1742                                    amount: oob_notes.total_amount(),
1743                                    oob_notes: oob_notes.to_string(),
1744                                },
1745                            )
1746                            .await;
1747
1748                        Ok((operation_id, oob_notes))
1749                    })
1750                },
1751                Some(100),
1752            )
1753            .await
1754            .map_err(|e| match e {
1755                AutocommitError::ClosureError { error, .. } => error,
1756                AutocommitError::CommitFailed { last_error, .. } => {
1757                    anyhow!("Commit to DB failed: {last_error}")
1758                }
1759            })
1760    }
1761
1762    /// Send e-cash notes for the requested amount.
1763    ///
1764    /// When this method removes ecash notes from the local database it will do
1765    /// so atomically with creating a `SendPaymentEvent` that contains the notes
1766    /// in out of band serilaized from. Hence it is critical for the integrator
1767    /// to display this event to ensure the user always has access to his funds.
1768    ///
1769    /// This method operates in two modes:
1770    ///
1771    /// 1. **Offline mode**: If exact notes are available in the wallet, they
1772    ///    are spent immediately without contacting the federation. A
1773    ///    `SendPaymentEvent` is emitted and the notes are returned.
1774    ///
1775    /// 2. **Online mode**: If exact notes are not available, the method
1776    ///    contacts the federation to trigger a reissuance transaction to obtain
1777    ///    the proper denominations. The method will block until the reissuance
1778    ///    completes, at which point a `SendPaymentEvent` is emitted and the
1779    ///    notes are returned.
1780    ///
1781    /// If the method enters online mode and is cancelled, e.g. the future is
1782    /// dropped, before the reissue transaction is confirmed, any reissued notes
1783    /// will be returned to the wallet and we do not emit a `SendPaymentEvent`.
1784    ///
1785    /// Before selection of the ecash notes the amount is rounded up to the
1786    /// nearest multiple of 512 msat.
1787    pub async fn send_oob_notes<M: Serialize + Send>(
1788        &self,
1789        amount: Amount,
1790        extra_meta: M,
1791    ) -> anyhow::Result<OOBNotes> {
1792        // Round up to the nearest multiple of 512 msat
1793        let amount = Amount::from_msats(amount.msats.div_ceil(512) * 512);
1794
1795        let extra_meta = serde_json::to_value(extra_meta)
1796            .expect("MintClientModule::send_oob_notes extra_meta is serializable");
1797
1798        // Try to spend exact notes from our current balance
1799        let oob_notes: Option<OOBNotes> = self
1800            .client_ctx
1801            .module_db()
1802            .autocommit(
1803                |dbtx, _| {
1804                    let extra_meta = extra_meta.clone();
1805                    Box::pin(async {
1806                        self.try_spend_exact_notes_dbtx(
1807                            dbtx,
1808                            amount,
1809                            self.federation_id,
1810                            extra_meta,
1811                        )
1812                        .await
1813                        .map(Ok::<OOBNotes, anyhow::Error>)
1814                        .transpose()
1815                    })
1816                },
1817                Some(100),
1818            )
1819            .await
1820            .expect("Failed to commit dbtx after 100 retries");
1821
1822        if let Some(oob_notes) = oob_notes {
1823            return Ok(oob_notes);
1824        }
1825
1826        // Verify we're online
1827        self.client_ctx
1828            .global_api()
1829            .session_count()
1830            .await
1831            .context("Cannot reach federation to reissue notes")?;
1832
1833        let operation_id = OperationId::new_random();
1834
1835        // Create outputs for reissuance using the existing create_output function
1836        let output_bundle = self
1837            .create_output(
1838                &mut self.client_ctx.module_db().begin_transaction_nc().await,
1839                operation_id,
1840                1, // notes_per_denomination
1841                amount,
1842            )
1843            .await;
1844
1845        // Combine the output bundle state machines with the send state machine
1846        let combined_bundle = ClientOutputBundle::new(
1847            output_bundle.outputs().to_vec(),
1848            output_bundle.sms().to_vec(),
1849        );
1850
1851        let outputs = self.client_ctx.make_client_outputs(combined_bundle);
1852
1853        let em_clone = extra_meta.clone();
1854
1855        // Submit reissuance transaction with the state machines
1856        let out_point_range = self
1857            .client_ctx
1858            .finalize_and_submit_transaction(
1859                operation_id,
1860                MintCommonInit::KIND.as_str(),
1861                move |change_range: OutPointRange| MintOperationMeta {
1862                    variant: MintOperationMetaVariant::Reissuance {
1863                        legacy_out_point: None,
1864                        txid: Some(change_range.txid()),
1865                        out_point_indices: change_range
1866                            .into_iter()
1867                            .map(|out_point| out_point.out_idx)
1868                            .collect(),
1869                    },
1870                    amount,
1871                    extra_meta: em_clone.clone(),
1872                },
1873                TransactionBuilder::new().with_outputs(outputs),
1874            )
1875            .await
1876            .context("Failed to submit reissuance transaction")?;
1877
1878        // Wait for outputs to be finalized
1879        self.client_ctx
1880            .await_primary_module_outputs(operation_id, out_point_range.into_iter().collect())
1881            .await
1882            .context("Failed to await output finalization")?;
1883
1884        // Recursively call send_oob_notes to try again with the reissued notes
1885        Box::pin(self.send_oob_notes(amount, extra_meta)).await
1886    }
1887
1888    /// Try to spend exact notes from the current balance.
1889    /// Returns `Some(OOBNotes)` if exact notes are available, `None` otherwise.
1890    async fn try_spend_exact_notes_dbtx(
1891        &self,
1892        dbtx: &mut DatabaseTransaction<'_>,
1893        amount: Amount,
1894        federation_id: FederationId,
1895        extra_meta: serde_json::Value,
1896    ) -> Option<OOBNotes> {
1897        let selected_notes = Self::select_notes(
1898            dbtx,
1899            &SelectNotesWithExactAmount,
1900            amount,
1901            FeeConsensus::zero(),
1902        )
1903        .await
1904        .ok()?;
1905
1906        // Remove notes from our database
1907        for (note_amount, note) in selected_notes.iter_items() {
1908            MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, note_amount, note)
1909                .await;
1910        }
1911
1912        let sender = self.balance_update_sender.clone();
1913        dbtx.on_commit(move || sender.send_replace(()));
1914
1915        let operation_id = spendable_notes_to_operation_id(&selected_notes);
1916
1917        let oob_notes = OOBNotes::new(federation_id.to_prefix(), selected_notes);
1918
1919        // Log the send operation with notes immediately available
1920        self.client_ctx
1921            .add_operation_log_entry_dbtx(
1922                dbtx,
1923                operation_id,
1924                MintCommonInit::KIND.as_str(),
1925                MintOperationMeta {
1926                    variant: MintOperationMetaVariant::SpendOOB {
1927                        requested_amount: amount,
1928                        oob_notes: oob_notes.clone(),
1929                    },
1930                    amount: oob_notes.total_amount(),
1931                    extra_meta,
1932                },
1933            )
1934            .await;
1935
1936        self.client_ctx
1937            .log_event(
1938                dbtx,
1939                SendPaymentEvent {
1940                    operation_id,
1941                    amount: oob_notes.total_amount(),
1942                    oob_notes: oob_notes.to_string(),
1943                },
1944            )
1945            .await;
1946
1947        Some(oob_notes)
1948    }
1949
1950    /// Validate the given notes and return the total amount of the notes.
1951    /// Validation checks that:
1952    /// - the federation ID is correct
1953    /// - the note has a valid signature
1954    /// - the spend key is correct.
1955    pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> {
1956        let federation_id_prefix = oob_notes.federation_id_prefix();
1957        let notes = oob_notes.notes().clone();
1958
1959        if federation_id_prefix != self.federation_id.to_prefix() {
1960            bail!("Federation ID does not match");
1961        }
1962
1963        let tbs_pks = &self.cfg.tbs_pks;
1964
1965        for (idx, (amt, snote)) in notes.iter_items().enumerate() {
1966            let key = tbs_pks
1967                .get(amt)
1968                .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
1969
1970            let note = snote.note();
1971            if !note.verify(*key) {
1972                bail!("Note {idx} has an invalid federation signature");
1973            }
1974
1975            let expected_nonce = Nonce(snote.spend_key.public_key());
1976            if note.nonce != expected_nonce {
1977                bail!("Note {idx} cannot be spent using the supplied spend key");
1978            }
1979        }
1980
1981        Ok(notes.total_amount())
1982    }
1983
1984    /// Contacts the mint and checks if the supplied notes were already spent.
1985    ///
1986    /// **Caution:** This reduces privacy and can lead to race conditions. **DO
1987    /// NOT** rely on it for receiving funds unless you really know what you are
1988    /// doing.
1989    pub async fn check_note_spent(&self, oob_notes: &OOBNotes) -> anyhow::Result<bool> {
1990        use crate::api::MintFederationApi;
1991
1992        let api_client = self.client_ctx.module_api();
1993        let any_spent = try_join_all(oob_notes.notes().iter().flat_map(|(_, notes)| {
1994            notes
1995                .iter()
1996                .map(|note| api_client.check_note_spent(note.nonce()))
1997        }))
1998        .await?
1999        .into_iter()
2000        .any(|spent| spent);
2001
2002        Ok(any_spent)
2003    }
2004
2005    /// Try to cancel a spend operation started with
2006    /// [`MintClientModule::spend_notes_with_selector`]. If the e-cash notes
2007    /// have already been spent this operation will fail which can be
2008    /// observed using [`MintClientModule::subscribe_spend_notes`].
2009    pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
2010        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2011        dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
2012            .await;
2013        if let Err(e) = dbtx.commit_tx_result().await {
2014            warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
2015        }
2016    }
2017
2018    /// Subscribe to updates on the progress of a raw e-cash spend operation
2019    /// started with [`MintClientModule::spend_notes_with_selector`].
2020    pub async fn subscribe_spend_notes(
2021        &self,
2022        operation_id: OperationId,
2023    ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
2024        let operation = self.mint_operation(operation_id).await?;
2025        if !matches!(
2026            operation.meta::<MintOperationMeta>().variant,
2027            MintOperationMetaVariant::SpendOOB { .. }
2028        ) {
2029            bail!("Operation is not a out-of-band spend");
2030        }
2031
2032        let client_ctx = self.client_ctx.clone();
2033
2034        Ok(self
2035            .client_ctx
2036            .outcome_or_updates(operation, operation_id, move || {
2037                stream! {
2038                    yield SpendOOBState::Created;
2039
2040                    let self_ref = client_ctx.self_ref();
2041
2042                    let refund = self_ref
2043                        .await_spend_oob_refund(operation_id)
2044                        .await;
2045
2046                    if refund.user_triggered {
2047                        yield SpendOOBState::UserCanceledProcessing;
2048                    }
2049
2050                    let mut success = true;
2051
2052                    for txid in refund.transaction_ids {
2053                        debug!(
2054                            target: LOG_CLIENT_MODULE_MINT,
2055                            %txid,
2056                            operation_id=%operation_id.fmt_short(),
2057                            "Waiting for oob refund txid"
2058                        );
2059                        if client_ctx
2060                            .transaction_updates(operation_id)
2061                            .await
2062                            .await_tx_accepted(txid)
2063                            .await.is_err() {
2064                                success = false;
2065                            }
2066                    }
2067
2068                    debug!(
2069                        target: LOG_CLIENT_MODULE_MINT,
2070                        operation_id=%operation_id.fmt_short(),
2071                        %success,
2072                        "Done waiting for all refund oob txids"
2073                     );
2074
2075                    match (refund.user_triggered, success) {
2076                        (true, true) => {
2077                            yield SpendOOBState::UserCanceledSuccess;
2078                        },
2079                        (true, false) => {
2080                            yield SpendOOBState::UserCanceledFailure;
2081                        },
2082                        (false, true) => {
2083                            yield SpendOOBState::Refunded;
2084                        },
2085                        (false, false) => {
2086                            yield SpendOOBState::Success;
2087                        }
2088                    }
2089                }
2090            }))
2091    }
2092
2093    async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
2094        let operation = self.client_ctx.get_operation(operation_id).await?;
2095
2096        if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
2097            bail!("Operation is not a mint operation");
2098        }
2099
2100        Ok(operation)
2101    }
2102
2103    async fn delete_spendable_note(
2104        client_ctx: &ClientContext<MintClientModule>,
2105        dbtx: &mut DatabaseTransaction<'_>,
2106        amount: Amount,
2107        note: &SpendableNote,
2108    ) {
2109        client_ctx
2110            .log_event(
2111                dbtx,
2112                NoteSpent {
2113                    nonce: note.nonce(),
2114                },
2115            )
2116            .await;
2117        dbtx.remove_entry(&NoteKey {
2118            amount,
2119            nonce: note.nonce(),
2120        })
2121        .await
2122        .expect("Must deleted existing spendable note");
2123    }
2124
2125    pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> {
2126        let db = self.client_ctx.module_db().clone();
2127
2128        Ok(db
2129            .autocommit(
2130                |dbtx, _| {
2131                    Box::pin(async {
2132                        Ok::<DerivableSecret, anyhow::Error>(
2133                            self.new_note_secret(amount, dbtx).await,
2134                        )
2135                    })
2136                },
2137                None,
2138            )
2139            .await?)
2140    }
2141
2142    /// Returns secrets for the note indices that were reused by previous
2143    /// clients with same client secret.
2144    pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
2145        self.client_ctx
2146            .module_db()
2147            .begin_transaction_nc()
2148            .await
2149            .get_value(&ReusedNoteIndices)
2150            .await
2151            .unwrap_or_default()
2152            .into_iter()
2153            .map(|(amount, note_idx)| {
2154                let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
2155                let (request, blind_nonce) =
2156                    NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
2157                (amount, request, blind_nonce)
2158            })
2159            .collect()
2160    }
2161}
2162
2163pub fn spendable_notes_to_operation_id(
2164    spendable_selected_notes: &TieredMulti<SpendableNote>,
2165) -> OperationId {
2166    OperationId(
2167        spendable_selected_notes
2168            .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
2169            .to_byte_array(),
2170    )
2171}
2172
2173#[derive(Debug, Serialize, Deserialize, Clone)]
2174pub struct SpendOOBRefund {
2175    pub user_triggered: bool,
2176    pub transaction_ids: Vec<TransactionId>,
2177}
2178
2179/// Defines a strategy for selecting e-cash notes given a specific target amount
2180/// and fee per note transaction input.
2181#[apply(async_trait_maybe_send!)]
2182pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
2183    /// Select notes from stream for `requested_amount`.
2184    /// The stream must produce items in non- decreasing order of amount.
2185    async fn select_notes(
2186        &self,
2187        // FIXME: async trait doesn't like maybe_add_send
2188        #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2189        #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2190        requested_amount: Amount,
2191        fee_consensus: FeeConsensus,
2192    ) -> anyhow::Result<TieredMulti<Note>>;
2193}
2194
2195/// Select notes with total amount of *at least* `request_amount`. If more than
2196/// requested amount of notes are returned it was because exact change couldn't
2197/// be made, and the next smallest amount will be returned.
2198///
2199/// The caller can request change from the federation.
2200pub struct SelectNotesWithAtleastAmount;
2201
2202#[apply(async_trait_maybe_send!)]
2203impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
2204    async fn select_notes(
2205        &self,
2206        #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2207        #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2208        requested_amount: Amount,
2209        fee_consensus: FeeConsensus,
2210    ) -> anyhow::Result<TieredMulti<Note>> {
2211        Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
2212    }
2213}
2214
2215/// Select notes with total amount of *exactly* `request_amount`. If the amount
2216/// cannot be represented with the available denominations an error is returned,
2217/// this **does not** mean that the balance is too low.
2218pub struct SelectNotesWithExactAmount;
2219
2220#[apply(async_trait_maybe_send!)]
2221impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
2222    async fn select_notes(
2223        &self,
2224        #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2225        #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2226        requested_amount: Amount,
2227        fee_consensus: FeeConsensus,
2228    ) -> anyhow::Result<TieredMulti<Note>> {
2229        let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
2230
2231        if notes.total_amount() != requested_amount {
2232            bail!(
2233                "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}",
2234                requested_amount,
2235                notes.total_amount()
2236            );
2237        }
2238
2239        Ok(notes)
2240    }
2241}
2242
2243// We are using a greedy algorithm to select notes. We start with the largest
2244// then proceed to the lowest tiers/denominations.
2245// But there is a catch: we don't know if there are enough notes in the lowest
2246// tiers, so we need to save a big note in case the sum of the following
2247// small notes are not enough.
2248async fn select_notes_from_stream<Note>(
2249    stream: impl futures::Stream<Item = (Amount, Note)>,
2250    requested_amount: Amount,
2251    fee_consensus: FeeConsensus,
2252) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
2253    if requested_amount == Amount::ZERO {
2254        return Ok(TieredMulti::default());
2255    }
2256    let mut stream = Box::pin(stream);
2257    let mut selected = vec![];
2258    // This is the big note we save in case the sum of the following small notes are
2259    // not sufficient to cover the pending amount
2260    // The tuple is (amount, note, checkpoint), where checkpoint is the index where
2261    // the note should be inserted on the selected vector if it is needed
2262    let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
2263    let mut pending_amount = requested_amount;
2264    let mut previous_amount: Option<Amount> = None; // used to assert descending order
2265    loop {
2266        if let Some((note_amount, note)) = stream.next().await {
2267            assert!(
2268                previous_amount.is_none_or(|previous| previous >= note_amount),
2269                "notes are not sorted in descending order"
2270            );
2271            previous_amount = Some(note_amount);
2272
2273            if note_amount <= fee_consensus.fee(note_amount) {
2274                continue;
2275            }
2276
2277            match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2278                Ordering::Less => {
2279                    // keep adding notes until we have enough
2280                    pending_amount += fee_consensus.fee(note_amount);
2281                    pending_amount -= note_amount;
2282                    selected.push((note_amount, note));
2283                }
2284                Ordering::Greater => {
2285                    // probably we don't need this big note, but we'll keep it in case the
2286                    // following small notes don't add up to the
2287                    // requested amount
2288                    last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2289                }
2290                Ordering::Equal => {
2291                    // exactly enough notes, return
2292                    selected.push((note_amount, note));
2293
2294                    let notes: TieredMulti<Note> = selected.into_iter().collect();
2295
2296                    assert!(
2297                        notes.total_amount().msats
2298                            >= requested_amount.msats
2299                                + notes
2300                                    .iter()
2301                                    .map(|note| fee_consensus.fee(note.0))
2302                                    .sum::<Amount>()
2303                                    .msats
2304                    );
2305
2306                    return Ok(notes);
2307                }
2308            }
2309        } else {
2310            assert!(pending_amount > Amount::ZERO);
2311            if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2312                // the sum of the small notes don't add up to the pending amount, remove
2313                // them
2314                selected.truncate(checkpoint);
2315                // and use the big note to cover it
2316                selected.push((big_note_amount, big_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                // so now we have enough to cover the requested amount, return
2331                return Ok(notes);
2332            }
2333
2334            let total_amount = requested_amount.saturating_sub(pending_amount);
2335            // not enough notes, return
2336            return Err(InsufficientBalanceError {
2337                requested_amount,
2338                total_amount,
2339            });
2340        }
2341    }
2342}
2343
2344#[derive(Debug, Clone, Error)]
2345pub struct InsufficientBalanceError {
2346    pub requested_amount: Amount,
2347    pub total_amount: Amount,
2348}
2349
2350impl std::fmt::Display for InsufficientBalanceError {
2351    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2352        write!(
2353            f,
2354            "Insufficient balance: requested {} but only {} available",
2355            self.requested_amount, self.total_amount
2356        )
2357    }
2358}
2359
2360/// Old and no longer used, will be deleted in the future
2361#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2362enum MintRestoreStates {
2363    #[encodable_default]
2364    Default { variant: u64, bytes: Vec<u8> },
2365}
2366
2367/// Old and no longer used, will be deleted in the future
2368#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2369pub struct MintRestoreStateMachine {
2370    operation_id: OperationId,
2371    state: MintRestoreStates,
2372}
2373
2374#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2375pub enum MintClientStateMachines {
2376    Output(MintOutputStateMachine),
2377    Input(MintInputStateMachine),
2378    OOB(MintOOBStateMachine),
2379    // Removed in https://github.com/fedimint/fedimint/pull/4035 , now ignored
2380    Restore(MintRestoreStateMachine),
2381}
2382
2383impl IntoDynInstance for MintClientStateMachines {
2384    type DynType = DynState;
2385
2386    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2387        DynState::from_typed(instance_id, self)
2388    }
2389}
2390
2391impl State for MintClientStateMachines {
2392    type ModuleContext = MintClientContext;
2393
2394    fn transitions(
2395        &self,
2396        context: &Self::ModuleContext,
2397        global_context: &DynGlobalClientContext,
2398    ) -> Vec<StateTransition<Self>> {
2399        match self {
2400            MintClientStateMachines::Output(issuance_state) => {
2401                sm_enum_variant_translation!(
2402                    issuance_state.transitions(context, global_context),
2403                    MintClientStateMachines::Output
2404                )
2405            }
2406            MintClientStateMachines::Input(redemption_state) => {
2407                sm_enum_variant_translation!(
2408                    redemption_state.transitions(context, global_context),
2409                    MintClientStateMachines::Input
2410                )
2411            }
2412            MintClientStateMachines::OOB(oob_state) => {
2413                sm_enum_variant_translation!(
2414                    oob_state.transitions(context, global_context),
2415                    MintClientStateMachines::OOB
2416                )
2417            }
2418            MintClientStateMachines::Restore(_) => {
2419                sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
2420            }
2421        }
2422    }
2423
2424    fn operation_id(&self) -> OperationId {
2425        match self {
2426            MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
2427            MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
2428            MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
2429            MintClientStateMachines::Restore(r) => r.operation_id,
2430        }
2431    }
2432}
2433
2434/// A [`Note`] with associated secret key that allows to proof ownership (spend
2435/// it)
2436#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
2437pub struct SpendableNote {
2438    pub signature: tbs::Signature,
2439    pub spend_key: Keypair,
2440}
2441
2442impl fmt::Debug for SpendableNote {
2443    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2444        f.debug_struct("SpendableNote")
2445            .field("nonce", &self.nonce())
2446            .field("signature", &self.signature)
2447            .field("spend_key", &self.spend_key)
2448            .finish()
2449    }
2450}
2451impl fmt::Display for SpendableNote {
2452    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2453        self.nonce().fmt(f)
2454    }
2455}
2456
2457impl SpendableNote {
2458    pub fn nonce(&self) -> Nonce {
2459        Nonce(self.spend_key.public_key())
2460    }
2461
2462    fn note(&self) -> Note {
2463        Note {
2464            nonce: self.nonce(),
2465            signature: self.signature,
2466        }
2467    }
2468
2469    pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
2470        SpendableNoteUndecoded {
2471            signature: self
2472                .signature
2473                .consensus_encode_to_vec()
2474                .try_into()
2475                .expect("Encoded size always correct"),
2476            spend_key: self.spend_key,
2477        }
2478    }
2479}
2480
2481/// A version of [`SpendableNote`] that didn't decode the `signature` yet
2482///
2483/// **Note**: signature decoding from raw bytes is faliable, as not all bytes
2484/// are valid signatures. Therefore this type must not be used for external
2485/// data, and should be limited to optimizing reading from internal database.
2486///
2487/// The signature bytes will be validated in [`Self::decode`].
2488///
2489/// Decoding [`tbs::Signature`] is somewhat CPU-intensive (see benches in this
2490/// crate), and when most of the result will be filtered away or completely
2491/// unused, it makes sense to skip/delay decoding.
2492#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
2493pub struct SpendableNoteUndecoded {
2494    // Need to keep this in sync with `tbs::Signature`, but there's a test
2495    // verifying they serialize and decode the same.
2496    #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
2497    pub signature: [u8; 48],
2498    pub spend_key: Keypair,
2499}
2500
2501impl fmt::Display for SpendableNoteUndecoded {
2502    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2503        self.nonce().fmt(f)
2504    }
2505}
2506
2507impl fmt::Debug for SpendableNoteUndecoded {
2508    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2509        f.debug_struct("SpendableNote")
2510            .field("nonce", &self.nonce())
2511            .field("signature", &"[raw]")
2512            .field("spend_key", &self.spend_key)
2513            .finish()
2514    }
2515}
2516
2517impl SpendableNoteUndecoded {
2518    fn nonce(&self) -> Nonce {
2519        Nonce(self.spend_key.public_key())
2520    }
2521
2522    pub fn decode(self) -> anyhow::Result<SpendableNote> {
2523        Ok(SpendableNote {
2524            signature: Decodable::consensus_decode_partial_from_finite_reader(
2525                &mut self.signature.as_slice(),
2526                &ModuleRegistry::default(),
2527            )?,
2528            spend_key: self.spend_key,
2529        })
2530    }
2531}
2532
2533/// An index used to deterministically derive [`Note`]s
2534///
2535/// We allow converting it to u64 and incrementing it, but
2536/// messing with it should be somewhat restricted to prevent
2537/// silly errors.
2538#[derive(
2539    Copy,
2540    Clone,
2541    Debug,
2542    Serialize,
2543    Deserialize,
2544    PartialEq,
2545    Eq,
2546    Encodable,
2547    Decodable,
2548    Default,
2549    PartialOrd,
2550    Ord,
2551)]
2552pub struct NoteIndex(u64);
2553
2554impl NoteIndex {
2555    pub fn next(self) -> Self {
2556        Self(self.0 + 1)
2557    }
2558
2559    fn prev(self) -> Option<Self> {
2560        self.0.checked_sub(0).map(Self)
2561    }
2562
2563    pub fn as_u64(self) -> u64 {
2564        self.0
2565    }
2566
2567    // Private. If it turns out it is useful outside,
2568    // we can relax and convert to `From<u64>`
2569    // Actually used in tests RN, so cargo complains in non-test builds.
2570    #[allow(unused)]
2571    pub fn from_u64(v: u64) -> Self {
2572        Self(v)
2573    }
2574
2575    pub fn advance(&mut self) {
2576        *self = self.next();
2577    }
2578}
2579
2580impl std::fmt::Display for NoteIndex {
2581    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2582        self.0.fmt(f)
2583    }
2584}
2585
2586struct OOBSpendTag;
2587
2588impl sha256t::Tag for OOBSpendTag {
2589    fn engine() -> sha256::HashEngine {
2590        let mut engine = sha256::HashEngine::default();
2591        engine.input(b"oob-spend");
2592        engine
2593    }
2594}
2595
2596struct OOBReissueTag;
2597
2598impl sha256t::Tag for OOBReissueTag {
2599    fn engine() -> sha256::HashEngine {
2600        let mut engine = sha256::HashEngine::default();
2601        engine.input(b"oob-reissue");
2602        engine
2603    }
2604}
2605
2606/// Determines the denominations to use when representing an amount
2607///
2608/// Algorithm tries to leave the user with a target number of
2609/// `denomination_sets` starting at the lowest denomination.  `self`
2610/// gives the denominations that the user already has.
2611pub fn represent_amount<K>(
2612    amount: Amount,
2613    current_denominations: &TieredCounts,
2614    tiers: &Tiered<K>,
2615    denomination_sets: u16,
2616    fee_consensus: &FeeConsensus,
2617) -> TieredCounts {
2618    let mut remaining_amount = amount;
2619    let mut denominations = TieredCounts::default();
2620
2621    // try to hit the target `denomination_sets`
2622    for tier in tiers.tiers() {
2623        let notes = current_denominations.get(*tier);
2624        let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
2625        let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
2626
2627        let add_notes = min(possible_notes, missing_notes);
2628        denominations.inc(*tier, add_notes as usize);
2629        remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
2630    }
2631
2632    // if there is a remaining amount, add denominations with a greedy algorithm
2633    for tier in tiers.tiers().rev() {
2634        let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
2635        remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
2636        denominations.inc(*tier, res as usize);
2637    }
2638
2639    let represented: u64 = denominations
2640        .iter()
2641        .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
2642        .sum();
2643
2644    assert!(represented <= amount.msats);
2645    assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
2646
2647    denominations
2648}
2649
2650pub(crate) fn create_bundle_for_inputs(
2651    inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
2652    operation_id: OperationId,
2653) -> ClientInputBundle<MintInput, MintClientStateMachines> {
2654    let mut inputs = Vec::new();
2655    let mut input_states = Vec::new();
2656
2657    for (input, spendable_note) in inputs_and_notes {
2658        input_states.push((input.amounts.clone(), spendable_note));
2659        inputs.push(input);
2660    }
2661
2662    let input_sm = Arc::new(move |out_point_range: OutPointRange| {
2663        debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
2664
2665        vec![MintClientStateMachines::Input(MintInputStateMachine {
2666            common: MintInputCommon {
2667                operation_id,
2668                out_point_range,
2669            },
2670            state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
2671                notes: input_states
2672                    .iter()
2673                    .map(|(amounts, note)| (amounts.expect_only_bitcoin(), *note))
2674                    .collect(),
2675            }),
2676        })]
2677    });
2678
2679    ClientInputBundle::new(
2680        inputs,
2681        vec![ClientInputSM {
2682            state_machines: input_sm,
2683        }],
2684    )
2685}
2686
2687#[cfg(test)]
2688mod tests {
2689    use std::fmt::Display;
2690    use std::str::FromStr;
2691
2692    use bitcoin_hashes::Hash;
2693    use fedimint_core::base32::FEDIMINT_PREFIX;
2694    use fedimint_core::config::FederationId;
2695    use fedimint_core::encoding::Decodable;
2696    use fedimint_core::invite_code::InviteCode;
2697    use fedimint_core::module::registry::ModuleRegistry;
2698    use fedimint_core::{
2699        Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
2700    };
2701    use fedimint_mint_common::config::FeeConsensus;
2702    use itertools::Itertools;
2703    use serde_json::json;
2704
2705    use crate::{
2706        MintOperationMetaVariant, OOBNotes, OOBNotesPart, SpendableNote, SpendableNoteUndecoded,
2707        represent_amount, select_notes_from_stream,
2708    };
2709
2710    #[test]
2711    fn represent_amount_targets_denomination_sets() {
2712        fn tiers(tiers: Vec<u64>) -> Tiered<()> {
2713            tiers
2714                .into_iter()
2715                .map(|tier| (Amount::from_sats(tier), ()))
2716                .collect()
2717        }
2718
2719        fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
2720            TieredCounts::from_iter(denominations)
2721        }
2722
2723        let starting = notes(vec![
2724            (Amount::from_sats(1), 1),
2725            (Amount::from_sats(2), 3),
2726            (Amount::from_sats(3), 2),
2727        ])
2728        .summary();
2729        let tiers = tiers(vec![1, 2, 3, 4]);
2730
2731        // target 3 tiers will fill out the 1 and 3 denominations
2732        assert_eq!(
2733            represent_amount(
2734                Amount::from_sats(6),
2735                &starting,
2736                &tiers,
2737                3,
2738                &FeeConsensus::zero()
2739            ),
2740            denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
2741        );
2742
2743        // target 2 tiers will fill out the 1 and 4 denominations
2744        assert_eq!(
2745            represent_amount(
2746                Amount::from_sats(6),
2747                &starting,
2748                &tiers,
2749                2,
2750                &FeeConsensus::zero()
2751            ),
2752            denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
2753        );
2754    }
2755
2756    #[test_log::test(tokio::test)]
2757    async fn select_notes_avg_test() {
2758        let max_amount = Amount::from_sats(1_000_000);
2759        let tiers = Tiered::gen_denominations(2, max_amount);
2760        let tiered = represent_amount::<()>(
2761            max_amount,
2762            &TieredCounts::default(),
2763            &tiers,
2764            3,
2765            &FeeConsensus::zero(),
2766        );
2767
2768        let mut total_notes = 0;
2769        for multiplier in 1..100 {
2770            let stream = reverse_sorted_note_stream(tiered.iter().collect());
2771            let select = select_notes_from_stream(
2772                stream,
2773                Amount::from_sats(multiplier * 1000),
2774                FeeConsensus::zero(),
2775            )
2776            .await;
2777            total_notes += select.unwrap().into_iter_items().count();
2778        }
2779        assert_eq!(total_notes / 100, 10);
2780    }
2781
2782    #[test_log::test(tokio::test)]
2783    async fn select_notes_returns_exact_amount_with_minimum_notes() {
2784        let f = || {
2785            reverse_sorted_note_stream(vec![
2786                (Amount::from_sats(1), 10),
2787                (Amount::from_sats(5), 10),
2788                (Amount::from_sats(20), 10),
2789            ])
2790        };
2791        assert_eq!(
2792            select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
2793                .await
2794                .unwrap(),
2795            notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
2796        );
2797        assert_eq!(
2798            select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
2799                .await
2800                .unwrap(),
2801            notes(vec![(Amount::from_sats(20), 1)])
2802        );
2803    }
2804
2805    #[test_log::test(tokio::test)]
2806    async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
2807        let stream = reverse_sorted_note_stream(vec![
2808            (Amount::from_sats(1), 1),
2809            (Amount::from_sats(5), 5),
2810            (Amount::from_sats(20), 5),
2811        ]);
2812        assert_eq!(
2813            select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
2814                .await
2815                .unwrap(),
2816            notes(vec![(Amount::from_sats(5), 2)])
2817        );
2818    }
2819
2820    #[test_log::test(tokio::test)]
2821    async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
2822        let stream = reverse_sorted_note_stream(vec![
2823            (Amount::from_sats(1), 3),
2824            (Amount::from_sats(5), 3),
2825            (Amount::from_sats(20), 2),
2826        ]);
2827        assert_eq!(
2828            select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
2829                .await
2830                .unwrap(),
2831            notes(vec![(Amount::from_sats(20), 2)])
2832        );
2833    }
2834
2835    #[test_log::test(tokio::test)]
2836    async fn select_notes_returns_error_if_amount_is_too_large() {
2837        let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
2838        let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
2839            .await
2840            .unwrap_err();
2841        assert_eq!(error.total_amount, Amount::from_sats(10));
2842    }
2843
2844    fn reverse_sorted_note_stream(
2845        notes: Vec<(Amount, usize)>,
2846    ) -> impl futures::Stream<Item = (Amount, String)> {
2847        futures::stream::iter(
2848            notes
2849                .into_iter()
2850                // We are creating `number` dummy notes of `amount` value
2851                .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2852                .sorted()
2853                .rev(),
2854        )
2855    }
2856
2857    fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
2858        notes
2859            .into_iter()
2860            .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2861            .collect()
2862    }
2863
2864    #[test]
2865    fn decoding_empty_oob_notes_fails() {
2866        let empty_oob_notes =
2867            OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
2868        let oob_notes_string = empty_oob_notes.to_string();
2869
2870        let res = oob_notes_string.parse::<OOBNotes>();
2871
2872        assert!(res.is_err(), "An empty OOB notes string should not parse");
2873    }
2874
2875    fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
2876    where
2877        T: FromStr + Display + crate::Encodable + crate::Decodable,
2878        <T as FromStr>::Err: std::fmt::Debug,
2879        F: Fn(T),
2880    {
2881        let data_parsed = data.to_string().parse().expect("Deserialization failed");
2882
2883        assertions(data_parsed);
2884
2885        let data_parsed = crate::base32::encode_prefixed(FEDIMINT_PREFIX, &data)
2886            .parse()
2887            .expect("Deserialization failed");
2888
2889        assertions(data_parsed);
2890
2891        assertions(data);
2892    }
2893
2894    #[test]
2895    fn notes_encode_decode() {
2896        let federation_id_1 =
2897            FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
2898        let federation_id_prefix_1 = federation_id_1.to_prefix();
2899        let federation_id_2 =
2900            FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
2901        let federation_id_prefix_2 = federation_id_2.to_prefix();
2902
2903        let notes = vec![(
2904            Amount::from_sats(1),
2905            SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
2906        )]
2907        .into_iter()
2908        .collect::<TieredMulti<_>>();
2909
2910        // Can decode inviteless notes
2911        let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
2912        test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
2913            assert_eq!(oob_notes.notes(), &notes);
2914            assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2915            assert_eq!(oob_notes.federation_invite(), None);
2916        });
2917
2918        // Can decode notes with invite
2919        let invite = InviteCode::new(
2920            "wss://foo.bar".parse().unwrap(),
2921            PeerId::from(0),
2922            federation_id_1,
2923            None,
2924        );
2925        let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
2926        test_roundtrip_serialize_str(notes_invite, |oob_notes| {
2927            assert_eq!(oob_notes.notes(), &notes);
2928            assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2929            assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
2930        });
2931
2932        // Can decode notes without federation id prefix, so we can optionally remove it
2933        // in the future
2934        let notes_no_prefix = OOBNotes(vec![
2935            OOBNotesPart::Notes(notes.clone()),
2936            OOBNotesPart::Invite {
2937                peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2938                federation_id: federation_id_1,
2939            },
2940        ]);
2941        test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
2942            assert_eq!(oob_notes.notes(), &notes);
2943            assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2944        });
2945
2946        // Rejects notes with inconsistent federation id
2947        let notes_inconsistent = OOBNotes(vec![
2948            OOBNotesPart::Notes(notes),
2949            OOBNotesPart::Invite {
2950                peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2951                federation_id: federation_id_1,
2952            },
2953            OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
2954        ]);
2955        let notes_inconsistent_str = notes_inconsistent.to_string();
2956        assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
2957    }
2958
2959    #[test]
2960    fn spendable_note_undecoded_sanity() {
2961        // TODO: add more hex dumps to the loop
2962        #[allow(clippy::single_element_loop)]
2963        for note_hex in [
2964            "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
2965        ] {
2966            let note =
2967                SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
2968            let note_undecoded =
2969                SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
2970                    .unwrap()
2971                    .decode()
2972                    .unwrap();
2973            assert_eq!(note, note_undecoded,);
2974            assert_eq!(
2975                serde_json::to_string(&note).unwrap(),
2976                serde_json::to_string(&note_undecoded).unwrap(),
2977            );
2978        }
2979    }
2980
2981    #[test]
2982    fn reissuance_meta_compatibility_02_03() {
2983        let dummy_outpoint = OutPoint {
2984            txid: TransactionId::all_zeros(),
2985            out_idx: 0,
2986        };
2987
2988        let old_meta_json = json!({
2989            "reissuance": {
2990                "out_point": dummy_outpoint
2991            }
2992        });
2993
2994        let old_meta: MintOperationMetaVariant =
2995            serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
2996        assert_eq!(
2997            old_meta,
2998            MintOperationMetaVariant::Reissuance {
2999                legacy_out_point: Some(dummy_outpoint),
3000                txid: None,
3001                out_point_indices: vec![],
3002            }
3003        );
3004
3005        let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
3006            legacy_out_point: None,
3007            txid: Some(dummy_outpoint.txid),
3008            out_point_indices: vec![0],
3009        })
3010        .expect("serializing always works");
3011        assert_eq!(
3012            new_meta_json,
3013            json!({
3014                "reissuance": {
3015                    "txid": dummy_outpoint.txid,
3016                    "out_point_indices": [dummy_outpoint.out_idx],
3017                }
3018            })
3019        );
3020    }
3021}