fedimint_mint_client/
lib.rs

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