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