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