fedimint_mint_client/
lib.rs

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