fedimint_mint_client/
lib.rs

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