fedimint_mint_client/
lib.rs

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