Skip to main content

fedimint_mintv2_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#![allow(clippy::too_many_lines)]
9
10pub use fedimint_mintv2_common as common;
11
12mod api;
13#[cfg(feature = "cli")]
14mod cli;
15mod client_db;
16mod ecash;
17mod events;
18mod input;
19pub mod issuance;
20mod output;
21mod receive;
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::convert::Infallible;
25use std::sync::{Arc, RwLock};
26use std::time::Duration;
27
28use anyhow::{Context as _, anyhow};
29use bitcoin_hashes::sha256;
30use client_db::{RecoveryState, RecoveryStateKey, SpendableNoteAmountPrefix, SpendableNotePrefix};
31pub use events::*;
32use fedimint_api_client::api::DynModuleApi;
33use fedimint_client::module::ClientModule;
34use fedimint_client::transaction::{
35    ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
36    ClientOutputSM, FeeQuote, FeeQuoteRequest, TransactionBuilder,
37};
38use fedimint_client_module::db::ClientModuleMigrationFn;
39use fedimint_client_module::module::init::{
40    ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs,
41};
42use fedimint_client_module::module::recovery::{NoModuleBackup, RecoveryProgress};
43use fedimint_client_module::module::{
44    ClientContext, OutPointRange, PrimaryModulePriority, PrimaryModuleSupport,
45};
46use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
47use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
48use fedimint_core::base32::{self, FEDIMINT_PREFIX};
49use fedimint_core::config::FederationId;
50use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
51use fedimint_core::db::{DatabaseTransaction, DatabaseVersion, IDatabaseTransactionOpsCoreTyped};
52use fedimint_core::encoding::{Decodable, Encodable};
53use fedimint_core::module::{
54    AmountUnit, Amounts, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
55};
56use fedimint_core::secp256k1::rand::{Rng, thread_rng};
57use fedimint_core::secp256k1::{Keypair, PublicKey};
58use fedimint_core::util::{BoxStream, NextOrPending};
59use fedimint_core::{Amount, OutPoint, PeerId, apply, async_trait_maybe_send};
60use fedimint_derive_secret::DerivableSecret;
61use fedimint_mintv2_common::config::{FeeConsensus, MintClientConfig, client_denominations};
62use fedimint_mintv2_common::{
63    Denomination, KIND, MintCommonInit, MintInput, MintModuleTypes, MintOutput, Note, RecoveryItem,
64};
65use futures::{StreamExt, pin_mut};
66use itertools::Itertools;
67use rand::seq::IteratorRandom;
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70use tbs::AggregatePublicKey;
71use thiserror::Error;
72
73use crate::api::MintV2ModuleApi;
74use crate::client_db::SpendableNoteKey;
75pub use crate::ecash::ECash;
76use crate::input::{InputSMCommon, InputSMState, InputStateMachine};
77use crate::issuance::NoteIssuanceRequest;
78use crate::output::{MintOutputStateMachine, OutputSMCommon, OutputSMState};
79use crate::receive::{ReceiveSMState, ReceiveStateMachine};
80
81const TARGET_PER_DENOMINATION: usize = 3;
82const SLICE_SIZE: u64 = 10000;
83const PARALLEL_HASH_REQUESTS: usize = 10;
84const PARALLEL_SLICE_REQUESTS: usize = 10;
85
86#[derive(Debug, Clone, PartialEq, Eq, Hash, Encodable, Decodable)]
87pub struct SpendableNote {
88    pub denomination: Denomination,
89    pub keypair: Keypair,
90    pub signature: tbs::Signature,
91}
92
93impl SpendableNote {
94    pub fn amount(&self) -> Amount {
95        self.denomination.amount()
96    }
97}
98
99impl SpendableNote {
100    fn nonce(&self) -> PublicKey {
101        self.keypair.public_key()
102    }
103
104    fn note(&self) -> Note {
105        Note {
106            denomination: self.denomination,
107            nonce: self.nonce(),
108            signature: self.signature,
109        }
110    }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub enum MintOperationMeta {
115    Send {
116        ecash: String,
117        custom_meta: Value,
118    },
119    Reissue {
120        change_outpoint_range: OutPointRange,
121        amount: Amount,
122        custom_meta: Value,
123    },
124    Receive {
125        change_outpoint_range: OutPointRange,
126        ecash: String,
127        custom_meta: Value,
128    },
129}
130
131#[derive(Debug, Clone)]
132pub struct MintClientInit;
133
134impl ModuleInit for MintClientInit {
135    type Common = MintCommonInit;
136
137    async fn dump_database(
138        &self,
139        _dbtx: &mut DatabaseTransaction<'_>,
140        _prefix_names: Vec<String>,
141    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
142        Box::new(BTreeMap::new().into_iter())
143    }
144}
145
146#[apply(async_trait_maybe_send!)]
147impl ClientModuleInit for MintClientInit {
148    type Module = MintClientModule;
149
150    fn supported_api_versions(&self) -> MultiApiVersion {
151        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 1 }])
152            .expect("no version conflicts")
153    }
154
155    async fn recover(
156        &self,
157        args: &ClientModuleRecoverArgs<Self>,
158        _snapshot: Option<&NoModuleBackup>,
159    ) -> anyhow::Result<Option<Amount>> {
160        let mut state = if let Some(state) = args
161            .db()
162            .begin_transaction_nc()
163            .await
164            .get_value(&RecoveryStateKey)
165            .await
166        {
167            state
168        } else {
169            RecoveryState {
170                next_index: 0,
171                total_items: args.module_api().fetch_recovery_count().await?,
172                requests: BTreeMap::new(),
173                nonces: BTreeSet::new(),
174            }
175        };
176
177        if state.next_index == state.total_items {
178            return Ok(None);
179        }
180
181        let peer_selector = PeerSelector::new(args.api().all_peers().clone());
182
183        let mut recovery_stream = futures::stream::iter(
184            (state.next_index..state.total_items).step_by(SLICE_SIZE as usize),
185        )
186        .map(|start| {
187            let api = args.module_api().clone();
188            let end = std::cmp::min(start + SLICE_SIZE, state.total_items);
189
190            async move { (start, end, api.fetch_recovery_slice_hash(start, end).await) }
191        })
192        .buffered(PARALLEL_HASH_REQUESTS)
193        .map(|(start, end, hash)| {
194            download_slice_with_hash(
195                args.module_api().clone(),
196                peer_selector.clone(),
197                start,
198                end,
199                hash,
200            )
201        })
202        .buffered(PARALLEL_SLICE_REQUESTS);
203
204        let tweak_filter = issuance::tweak_filter(args.module_root_secret());
205
206        loop {
207            let items = recovery_stream
208                .next()
209                .await
210                .context("Recovery stream finished before recovery is complete")?;
211
212            for item in &items {
213                match item {
214                    RecoveryItem::Output {
215                        denomination,
216                        nonce_hash,
217                        tweak,
218                    } => {
219                        if !issuance::check_tweak(*tweak, tweak_filter) {
220                            continue;
221                        }
222                        let output_secret = issuance::output_secret(
223                            *denomination,
224                            *tweak,
225                            args.module_root_secret(),
226                        );
227
228                        if !issuance::check_nonce(&output_secret, *nonce_hash) {
229                            continue;
230                        }
231
232                        let computed_nonce_hash = issuance::nonce(&output_secret).consensus_hash();
233
234                        // Ignore possible duplicate nonces
235                        if !state.nonces.insert(computed_nonce_hash) {
236                            continue;
237                        }
238
239                        state.requests.insert(
240                            computed_nonce_hash,
241                            NoteIssuanceRequest::new(
242                                *denomination,
243                                *tweak,
244                                args.module_root_secret(),
245                            ),
246                        );
247                    }
248                    RecoveryItem::Input { nonce_hash } => {
249                        state.requests.remove(nonce_hash);
250                        state.nonces.remove(nonce_hash);
251                    }
252                }
253            }
254
255            state.next_index += items.len() as u64;
256
257            let mut dbtx = args.db().begin_transaction().await;
258
259            dbtx.insert_entry(&RecoveryStateKey, &state).await;
260
261            if state.next_index == state.total_items {
262                // Total value of the notes reconstructed during recovery
263                let recovered_amount = state
264                    .requests
265                    .values()
266                    .map(|request| request.denomination.amount())
267                    .sum::<Amount>();
268
269                let state_machines = args
270                    .context()
271                    .map_dyn(vec![MintClientStateMachines::Output(
272                        MintOutputStateMachine {
273                            common: OutputSMCommon {
274                                operation_id: OperationId::new_random(),
275                                range: None,
276                                issuance_requests: state.requests.into_values().collect(),
277                            },
278                            state: OutputSMState::Pending,
279                        },
280                    )])
281                    .collect();
282
283                args.context()
284                    .add_state_machines_dbtx(&mut dbtx.to_ref_nc(), state_machines)
285                    .await
286                    .expect("state machine is valid");
287
288                dbtx.commit_tx().await;
289
290                return Ok(Some(recovered_amount));
291            }
292
293            dbtx.commit_tx().await;
294
295            args.update_recovery_progress(RecoveryProgress {
296                complete: state.next_index.try_into().unwrap_or(u32::MAX),
297                total: state.total_items.try_into().unwrap_or(u32::MAX),
298            });
299        }
300    }
301
302    async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
303        let (tweak_sender, tweak_receiver) = async_channel::bounded(50);
304
305        let filter = issuance::tweak_filter(args.module_root_secret());
306
307        // Only ~1/65536 random tweaks pass the filter, so grinding is
308        // CPU-bound. Pre-grind in the background and feed tweaks through a
309        // channel to keep the cost off the spend path. `send` only suspends
310        // once the channel is full, so we yield explicitly on each found tweak
311        // to keep single-threaded (wasm) runtimes responsive while the channel
312        // still has space.
313        fedimint_core::task::spawn("mintv2-tweak-grinder", async move {
314            loop {
315                let tweak: [u8; 16] = thread_rng().r#gen();
316
317                if !issuance::check_tweak(tweak, filter) {
318                    continue;
319                }
320
321                if tweak_sender.send(tweak).await.is_err() {
322                    return;
323                }
324
325                fedimint_core::task::sleep(Duration::ZERO).await;
326            }
327        });
328
329        Ok(MintClientModule {
330            federation_id: *args.federation_id(),
331            cfg: args.cfg().clone(),
332            root_secret: args.module_root_secret().clone(),
333            notifier: args.notifier().clone(),
334            client_ctx: args.context(),
335            balance_update_sender: tokio::sync::watch::channel(()).0,
336            tweak_receiver,
337        })
338    }
339
340    fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
341        BTreeMap::new()
342    }
343}
344
345#[derive(Debug)]
346pub struct MintClientModule {
347    federation_id: FederationId,
348    cfg: MintClientConfig,
349    root_secret: DerivableSecret,
350    notifier: ModuleNotifier<MintClientStateMachines>,
351    client_ctx: ClientContext<Self>,
352    balance_update_sender: tokio::sync::watch::Sender<()>,
353    tweak_receiver: async_channel::Receiver<[u8; 16]>,
354}
355
356#[derive(Debug, Clone)]
357pub struct MintClientContext {
358    client_ctx: ClientContext<MintClientModule>,
359    tbs_agg_pks: BTreeMap<Denomination, AggregatePublicKey>,
360    tbs_pks: BTreeMap<Denomination, BTreeMap<PeerId, tbs::PublicKeyShare>>,
361    pub balance_update_sender: tokio::sync::watch::Sender<()>,
362}
363
364impl Context for MintClientContext {
365    const KIND: Option<ModuleKind> = Some(KIND);
366}
367
368#[apply(async_trait_maybe_send!)]
369impl ClientModule for MintClientModule {
370    type Init = MintClientInit;
371    type Common = MintModuleTypes;
372    type Backup = NoModuleBackup;
373    type ModuleStateMachineContext = MintClientContext;
374    type States = MintClientStateMachines;
375
376    fn context(&self) -> Self::ModuleStateMachineContext {
377        MintClientContext {
378            client_ctx: self.client_ctx.clone(),
379            tbs_agg_pks: self.cfg.tbs_agg_pks.clone(),
380            tbs_pks: self.cfg.tbs_pks.clone(),
381            balance_update_sender: self.balance_update_sender.clone(),
382        }
383    }
384
385    fn input_fee(
386        &self,
387        amounts: &Amounts,
388        _input: &<Self::Common as ModuleCommon>::Input,
389    ) -> Option<Amounts> {
390        let unit = self.cfg.amount_unit;
391        let amount = amounts.get(&unit).copied().unwrap_or_default();
392        let fee = self.cfg.fee_consensus.fee(amount);
393
394        Some(Amounts::new_custom(unit, fee))
395    }
396
397    fn output_fee(
398        &self,
399        amounts: &Amounts,
400        _output: &<Self::Common as ModuleCommon>::Output,
401    ) -> Option<Amounts> {
402        let unit = self.cfg.amount_unit;
403        let amount = amounts.get(&unit).copied().unwrap_or_default();
404        let fee = self.cfg.fee_consensus.fee(amount);
405
406        Some(Amounts::new_custom(unit, fee))
407    }
408
409    #[cfg(feature = "cli")]
410    async fn handle_cli_command(
411        &self,
412        args: &[std::ffi::OsString],
413    ) -> anyhow::Result<serde_json::Value> {
414        cli::handle_cli_command(self, args).await
415    }
416
417    fn supports_being_primary(&self) -> PrimaryModuleSupport {
418        PrimaryModuleSupport::selected(PrimaryModulePriority::HIGH, [self.cfg.amount_unit])
419    }
420
421    async fn create_final_inputs_and_outputs(
422        &self,
423        dbtx: &mut DatabaseTransaction<'_>,
424        operation_id: OperationId,
425        unit: AmountUnit,
426        mut input_amount: Amount,
427        mut output_amount: Amount,
428    ) -> anyhow::Result<(
429        ClientInputBundle<MintInput, MintClientStateMachines>,
430        ClientOutputBundle<MintOutput, MintClientStateMachines>,
431    )> {
432        if unit != self.cfg.amount_unit {
433            anyhow::bail!("Module can only handle its configured amount unit");
434        }
435
436        let funding_notes = self
437            .select_funding_input(dbtx, output_amount.saturating_sub(input_amount))
438            .await
439            .context("Insufficient funds")?;
440
441        for note in &funding_notes {
442            self.remove_spendable_note(dbtx, note).await;
443        }
444
445        input_amount += funding_notes.iter().map(SpendableNote::amount).sum();
446
447        output_amount += funding_notes
448            .iter()
449            .map(|input| self.cfg.fee_consensus.fee(input.amount()))
450            .sum();
451
452        assert!(output_amount <= input_amount);
453
454        let (input_notes, output_amounts) = self
455            .rebalance(dbtx, &self.cfg.fee_consensus, input_amount - output_amount)
456            .await;
457
458        for note in &input_notes {
459            self.remove_spendable_note(dbtx, note).await;
460        }
461
462        input_amount += input_notes.iter().map(SpendableNote::amount).sum();
463
464        output_amount += input_notes
465            .iter()
466            .map(|note| self.cfg.fee_consensus.fee(note.amount()))
467            .sum();
468
469        output_amount += output_amounts
470            .iter()
471            .map(|denomination| {
472                denomination.amount() + self.cfg.fee_consensus.fee(denomination.amount())
473            })
474            .sum();
475
476        assert!(output_amount <= input_amount);
477
478        let mut spendable_notes = funding_notes
479            .into_iter()
480            .chain(input_notes)
481            .collect::<Vec<SpendableNote>>();
482
483        // We sort the notes by denomination to minimize the leaked information.
484        spendable_notes.sort_by_key(|note| note.denomination);
485
486        let input_bundle =
487            Self::create_input_bundle(operation_id, spendable_notes, false, self.cfg.amount_unit);
488
489        let mut denominations = represent_amount_with_fees(
490            input_amount.saturating_sub(output_amount),
491            &self.cfg.fee_consensus,
492        )
493        .into_iter()
494        .chain(output_amounts)
495        .collect::<Vec<Denomination>>();
496
497        // We sort the amounts to minimize the leaked information.
498        denominations.sort();
499
500        let output_bundle = self.create_output_bundle(operation_id, denominations).await;
501
502        let sender = self.balance_update_sender.clone();
503        dbtx.on_commit(move || sender.send_replace(()));
504
505        Ok((input_bundle, output_bundle))
506    }
507
508    async fn await_primary_module_output(
509        &self,
510        operation_id: OperationId,
511        outpoint: OutPoint,
512    ) -> anyhow::Result<()> {
513        self.await_output_sm_success(operation_id, outpoint).await
514    }
515
516    async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
517        if unit != self.cfg.amount_unit {
518            return Amount::ZERO;
519        }
520
521        self.get_count_by_denomination_dbtx(dbtx)
522            .await
523            .into_iter()
524            .map(|(denomination, count)| denomination.amount().mul_u64(count))
525            .sum()
526    }
527
528    async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
529        Box::pin(tokio_stream::wrappers::WatchStream::new(
530            self.balance_update_sender.subscribe(),
531        ))
532    }
533}
534
535impl MintClientModule {
536    async fn select_funding_input(
537        &self,
538        dbtx: &mut DatabaseTransaction<'_>,
539        mut excess_output: Amount,
540    ) -> Option<Vec<SpendableNote>> {
541        let mut selected_notes = Vec::new();
542        let mut target_notes = Vec::new();
543        let mut excess_notes = Vec::new();
544
545        for amount in client_denominations().rev() {
546            let notes_amount = dbtx
547                .find_by_prefix(&SpendableNoteAmountPrefix(amount))
548                .await
549                .map(|entry| entry.0.0)
550                .collect::<Vec<SpendableNote>>()
551                .await;
552
553            target_notes.extend(notes_amount.iter().take(TARGET_PER_DENOMINATION).cloned());
554
555            if notes_amount.len() > 2 * TARGET_PER_DENOMINATION {
556                for note in notes_amount.into_iter().skip(TARGET_PER_DENOMINATION) {
557                    let note_fee = self.cfg.fee_consensus.fee(note.amount());
558
559                    let note_value = note
560                        .amount()
561                        .checked_sub(note_fee)
562                        .expect("All our notes are economical");
563
564                    excess_output = excess_output.saturating_sub(note_value);
565
566                    selected_notes.push(note);
567                }
568            } else {
569                excess_notes.extend(notes_amount.into_iter().skip(TARGET_PER_DENOMINATION));
570            }
571        }
572
573        if excess_output == Amount::ZERO {
574            return Some(selected_notes);
575        }
576
577        for note in excess_notes.into_iter().chain(target_notes) {
578            let note_amount = note.amount();
579            let note_value = note_amount
580                .checked_sub(self.cfg.fee_consensus.fee(note_amount))
581                .expect("All our notes are economical");
582
583            excess_output = excess_output.saturating_sub(note_value);
584
585            selected_notes.push(note);
586
587            if excess_output == Amount::ZERO {
588                return Some(selected_notes);
589            }
590        }
591
592        None
593    }
594
595    async fn rebalance(
596        &self,
597        dbtx: &mut DatabaseTransaction<'_>,
598        fee: &FeeConsensus,
599        mut excess_input: Amount,
600    ) -> (Vec<SpendableNote>, Vec<Denomination>) {
601        let n_denominations = self.get_count_by_denomination_dbtx(dbtx).await;
602
603        let mut notes = dbtx
604            .find_by_prefix_sorted_descending(&SpendableNotePrefix)
605            .await
606            .map(|entry| entry.0.0)
607            .fuse();
608
609        let mut input_notes = Vec::new();
610        let mut output_denominations = Vec::new();
611
612        for d in client_denominations() {
613            let n_denomination = n_denominations.get(&d).copied().unwrap_or(0);
614
615            let n_missing = TARGET_PER_DENOMINATION.saturating_sub(n_denomination as usize);
616
617            for _ in 0..n_missing {
618                match excess_input.checked_sub(d.amount() + fee.fee(d.amount())) {
619                    Some(remaining_excess) => excess_input = remaining_excess,
620                    None => match notes.next().await {
621                        Some(note) => {
622                            if note.amount() <= d.amount() + fee.fee(d.amount()) {
623                                break;
624                            }
625
626                            excess_input += note.amount() - (d.amount() + fee.fee(d.amount()));
627
628                            input_notes.push(note);
629                        }
630                        None => break,
631                    },
632                }
633
634                output_denominations.push(d);
635            }
636        }
637
638        (input_notes, output_denominations)
639    }
640
641    fn create_input_bundle(
642        operation_id: OperationId,
643        notes: Vec<SpendableNote>,
644        include_receive_sm: bool,
645        amount_unit: AmountUnit,
646    ) -> ClientInputBundle<MintInput, MintClientStateMachines> {
647        let inputs = notes
648            .iter()
649            .map(|spendable_note| ClientInput {
650                input: MintInput::new_v0(spendable_note.note()),
651                keys: vec![spendable_note.keypair],
652                amounts: Amounts::new_custom(amount_unit, spendable_note.amount()),
653            })
654            .collect();
655
656        let input_sms = vec![ClientInputSM {
657            state_machines: Arc::new(move |range: OutPointRange| {
658                let mut sms = vec![MintClientStateMachines::Input(InputStateMachine {
659                    common: InputSMCommon {
660                        operation_id,
661                        txid: range.txid(),
662                        spendable_notes: notes.clone(),
663                    },
664                    state: InputSMState::Pending,
665                })];
666
667                if include_receive_sm {
668                    sms.push(MintClientStateMachines::Receive(ReceiveStateMachine {
669                        common: crate::receive::ReceiveSMCommon {
670                            operation_id,
671                            txid: range.txid(),
672                        },
673                        state: crate::receive::ReceiveSMState::Pending,
674                    }));
675                }
676
677                sms
678            }),
679        }];
680
681        ClientInputBundle::new(inputs, input_sms)
682    }
683
684    async fn create_output_bundle(
685        &self,
686        operation_id: OperationId,
687        requested_denominations: Vec<Denomination>,
688    ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
689        let issuance_requests = futures::stream::iter(requested_denominations)
690            .zip(self.tweak_receiver.clone())
691            .map(|(d, tweak)| NoteIssuanceRequest::new(d, tweak, &self.root_secret))
692            .collect::<Vec<NoteIssuanceRequest>>()
693            .await;
694
695        let amount_unit = self.cfg.amount_unit;
696        let outputs = issuance_requests
697            .iter()
698            .map(|request| ClientOutput {
699                output: request.output(),
700                amounts: Amounts::new_custom(amount_unit, request.denomination.amount()),
701            })
702            .collect();
703
704        let output_sms = vec![ClientOutputSM {
705            state_machines: Arc::new(move |range: OutPointRange| {
706                vec![MintClientStateMachines::Output(MintOutputStateMachine {
707                    common: OutputSMCommon {
708                        operation_id,
709                        range: Some(range),
710                        issuance_requests: issuance_requests.clone(),
711                    },
712                    state: OutputSMState::Pending,
713                })]
714            }),
715        }];
716
717        ClientOutputBundle::new(outputs, output_sms)
718    }
719
720    async fn await_output_sm_success(
721        &self,
722        operation_id: OperationId,
723        outpoint: OutPoint,
724    ) -> anyhow::Result<()> {
725        let stream = self
726            .notifier
727            .subscribe(operation_id)
728            .await
729            .filter_map(|state| async {
730                let MintClientStateMachines::Output(state) = state else {
731                    return None;
732                };
733
734                if !state.common.range?.into_iter().contains(&outpoint) {
735                    return None;
736                }
737
738                match state.state {
739                    OutputSMState::Pending => None,
740                    OutputSMState::Success => Some(Ok(())),
741                    OutputSMState::Aborted => Some(Err(anyhow!("Transaction was rejected"))),
742                    OutputSMState::Failure => Some(Err(anyhow!("Failed to finalize notes",))),
743                }
744            });
745
746        pin_mut!(stream);
747
748        stream.next_or_pending().await
749    }
750
751    /// Count the `ECash` notes in the client's database by denomination.
752    pub async fn get_count_by_denomination(&self) -> BTreeMap<Denomination, u64> {
753        self.get_count_by_denomination_dbtx(
754            &mut self.client_ctx.module_db().begin_transaction_nc().await,
755        )
756        .await
757    }
758
759    async fn get_count_by_denomination_dbtx(
760        &self,
761        dbtx: &mut DatabaseTransaction<'_>,
762    ) -> BTreeMap<Denomination, u64> {
763        dbtx.find_by_prefix(&SpendableNotePrefix)
764            .await
765            .fold(BTreeMap::new(), |mut acc, entry| async move {
766                acc.entry(entry.0.0.denomination)
767                    .and_modify(|count| *count += 1)
768                    .or_insert(1);
769
770                acc
771            })
772            .await
773    }
774
775    /// Send `ECash` for the given amount and return the send operation ID. The
776    /// amount will be rounded up to a multiple of 512 msats which is the
777    /// smallest denomination used throughout the client. If the rounded
778    /// amount cannot be covered with the ecash notes in the client's
779    /// database the client will create a transaction to reissue the
780    /// required denominations. It is safe to cancel the send method call
781    /// before the reissue is complete in which case the reissued notes are
782    /// returned to the regular balance. To cancel a successful ecash send
783    /// simply receive it yourself.
784    ///
785    /// If `include_invite` is set, the federation's invite code is embedded in
786    /// the returned ecash so a recipient that has not joined the federation can
787    /// do so directly from the received ecash.
788    pub async fn send(
789        &self,
790        amount: Amount,
791        custom_meta: Value,
792        include_invite: bool,
793    ) -> Result<(OperationId, ECash), SendECashError> {
794        let amount = round_to_multiple(amount, client_denominations().next().unwrap().amount());
795
796        if let Some((operation_id, ecash)) = self
797            .client_ctx
798            .module_db()
799            .autocommit(
800                |dbtx, _| {
801                    Box::pin(self.send_ecash_dbtx(
802                        dbtx,
803                        amount,
804                        custom_meta.clone(),
805                        include_invite,
806                    ))
807                },
808                Some(100),
809            )
810            .await
811            .expect("Failed to commit dbtx after 100 retries")
812        {
813            return Ok((operation_id, ecash));
814        }
815
816        self.client_ctx
817            .global_api()
818            .session_count()
819            .await
820            .map_err(|_| SendECashError::Offline)?;
821
822        let operation_id = OperationId::new_random();
823
824        let output = self
825            .create_output_bundle(operation_id, represent_amount(amount))
826            .await;
827        let output = self.client_ctx.make_client_outputs(output);
828        let cm = custom_meta.clone();
829
830        let range = self
831            .client_ctx
832            .finalize_and_submit_transaction(
833                operation_id,
834                MintCommonInit::KIND.as_str(),
835                move |change_outpoint_range| MintOperationMeta::Reissue {
836                    change_outpoint_range,
837                    amount,
838                    custom_meta: cm.clone(),
839                },
840                TransactionBuilder::new().with_outputs(output),
841            )
842            .await
843            .map_err(|_| SendECashError::InsufficientBalance)?;
844
845        for outpoint in range {
846            self.await_output_sm_success(operation_id, outpoint)
847                .await
848                .map_err(|_| SendECashError::Failure)?;
849        }
850
851        Box::pin(self.send(amount, custom_meta, include_invite)).await
852    }
853
854    async fn send_ecash_dbtx(
855        &self,
856        dbtx: &mut DatabaseTransaction<'_>,
857        remaining_amount: Amount,
858        custom_meta: Value,
859        include_invite: bool,
860    ) -> Result<Option<(OperationId, ECash)>, Infallible> {
861        let Some(notes) = Self::select_exact_change(&mut dbtx.to_ref_nc(), remaining_amount).await
862        else {
863            return Ok(None);
864        };
865
866        for spendable_note in &notes {
867            self.remove_spendable_note(dbtx, spendable_note).await;
868        }
869
870        let ecash = if include_invite {
871            let invite = self.client_ctx.get_invite_code().await;
872            ECash::new_with_invite(notes, &invite)
873        } else {
874            ECash::new(self.federation_id, notes)
875        };
876        let amount = ecash.amount();
877        let operation_id = OperationId::new_random();
878
879        self.client_ctx
880            .add_operation_log_entry_dbtx(
881                dbtx,
882                operation_id,
883                MintCommonInit::KIND.as_str(),
884                MintOperationMeta::Send {
885                    ecash: base32::encode_prefixed(FEDIMINT_PREFIX, &ecash),
886                    custom_meta,
887                },
888            )
889            .await;
890
891        self.client_ctx
892            .log_event(
893                dbtx,
894                SendPaymentEvent {
895                    operation_id,
896                    amount,
897                    ecash: base32::encode_prefixed(FEDIMINT_PREFIX, &ecash),
898                },
899            )
900            .await;
901
902        let sender = self.balance_update_sender.clone();
903        dbtx.on_commit(move || sender.send_replace(()));
904
905        Ok(Some((operation_id, ecash)))
906    }
907
908    /// Receive the `ECash` by reissuing the notes and return the total amount
909    /// of the ecash reissued. This method is idempotent.
910    pub async fn receive(
911        &self,
912        ecash: ECash,
913        custom_meta: Value,
914    ) -> Result<OperationId, ReceiveECashError> {
915        let operation_id = OperationId::from_encodable(&ecash);
916
917        if self.client_ctx.operation_exists(operation_id).await {
918            return Ok(operation_id);
919        }
920
921        if ecash.mint() != Some(self.federation_id) {
922            return Err(ReceiveECashError::WrongFederation);
923        }
924
925        if ecash
926            .notes()
927            .iter()
928            .any(|note| note.amount() <= self.cfg.fee_consensus.base_fee())
929        {
930            return Err(ReceiveECashError::UneconomicalDenomination);
931        }
932
933        let input =
934            Self::create_input_bundle(operation_id, ecash.notes(), true, self.cfg.amount_unit);
935        let input = self.client_ctx.make_client_inputs(input);
936        let ec = base32::encode_prefixed(FEDIMINT_PREFIX, &ecash);
937
938        self.client_ctx
939            .finalize_and_submit_transaction(
940                operation_id,
941                MintCommonInit::KIND.as_str(),
942                move |change_outpoint_range| MintOperationMeta::Receive {
943                    change_outpoint_range,
944                    ecash: ec.clone(),
945                    custom_meta: custom_meta.clone(),
946                },
947                TransactionBuilder::new().with_inputs(input),
948            )
949            .await
950            .map_err(|_| ReceiveECashError::InsufficientFunds)?;
951
952        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
953
954        self.client_ctx
955            .log_event(
956                &mut dbtx,
957                ReceivePaymentEvent {
958                    operation_id,
959                    amount: ecash.amount(),
960                },
961            )
962            .await;
963
964        dbtx.commit_tx().await;
965
966        Ok(operation_id)
967    }
968
969    /// Computes the exact fee a `receive(ecash)` would incur given the client's
970    /// current note inventory, without submitting anything.
971    ///
972    /// This runs the same change generation the real receive does
973    /// (`create_final_inputs_and_outputs`, including funding selection and
974    /// rebalancing) against a non-committable transaction that is dropped
975    /// rather than committed, so the client's notes are read but left
976    /// untouched. The quote is point-in-time: it depends on the current
977    /// inventory and can move as notes change.
978    pub async fn receive_fee_quote(&self, ecash: &ECash) -> anyhow::Result<FeeQuote> {
979        // A receive submits the ecash notes as explicit inputs and no explicit
980        // outputs; the shared, module-agnostic fee quote runs the primary-module
981        // balancing (rebalancing + minting change) over the real inventory.
982        let notes = ecash.notes();
983        let input_amount: Amount = notes.iter().map(SpendableNote::amount).sum();
984        let input_fee: Amount = notes
985            .iter()
986            .map(|note| self.cfg.fee_consensus.fee(note.amount()))
987            .sum();
988
989        self.client_ctx
990            .fee_quote(
991                OperationId::new_random(),
992                FeeQuoteRequest {
993                    input_amount: Amounts::new_custom(self.cfg.amount_unit, input_amount),
994                    output_amount: Amounts::ZERO,
995                    input_fee: Amounts::new_custom(self.cfg.amount_unit, input_fee),
996                    output_fee: Amounts::ZERO,
997                },
998            )
999            .await
1000    }
1001
1002    /// Computes the fee a `send(amount)` would incur given the client's current
1003    /// note inventory, without sending anything.
1004    ///
1005    /// A send is free when the client's existing notes can cover the (rounded)
1006    /// amount exactly — it just hands those notes out. Otherwise the send first
1007    /// reissues itself the right denominations, and that self-reissue
1008    /// transaction is the only thing a send ever pays a fee for. This quote
1009    /// mirrors that: it returns [`FeeQuote::ZERO`] when exact change is
1010    /// available, and otherwise quotes the reissue the same way the real send
1011    /// submits it (explicit outputs `represent_amount(amount)`, no explicit
1012    /// inputs) via the shared, module-agnostic fee quote over the real
1013    /// inventory. The quote is point-in-time: it depends on the current
1014    /// inventory and can move as notes change.
1015    pub async fn send_fee_quote(&self, amount: Amount) -> anyhow::Result<FeeQuote> {
1016        let amount = round_to_multiple(amount, client_denominations().next().unwrap().amount());
1017
1018        // Exact-change path: handing out existing notes never costs a fee.
1019        if self.can_make_exact_change(amount).await {
1020            return Ok(FeeQuote::ZERO);
1021        }
1022
1023        // Reissue path: the send mints itself `represent_amount(amount)` as
1024        // explicit outputs (no explicit inputs) and the primary module funds and
1025        // balances it. Quote that exact transaction.
1026        let denominations = represent_amount(amount);
1027        let output_amount: Amount = denominations.iter().map(|d| d.amount()).sum();
1028        let output_fee: Amount = denominations
1029            .iter()
1030            .map(|d| self.cfg.fee_consensus.fee(d.amount()))
1031            .sum();
1032
1033        self.client_ctx
1034            .fee_quote(
1035                OperationId::new_random(),
1036                FeeQuoteRequest {
1037                    input_amount: Amounts::ZERO,
1038                    output_amount: Amounts::new_custom(self.cfg.amount_unit, output_amount),
1039                    input_fee: Amounts::ZERO,
1040                    output_fee: Amounts::new_custom(self.cfg.amount_unit, output_fee),
1041                },
1042            )
1043            .await
1044    }
1045
1046    /// Returns whether the client's current notes can be handed out to cover
1047    /// `amount` exactly — the free path in [`Self::send`] — without modifying
1048    /// the inventory. Shares its greedy selection with
1049    /// [`Self::send_ecash_dbtx`] via [`Self::select_exact_change`].
1050    async fn can_make_exact_change(&self, remaining_amount: Amount) -> bool {
1051        let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1052
1053        Self::select_exact_change(&mut dbtx, remaining_amount)
1054            .await
1055            .is_some()
1056    }
1057
1058    /// Greedily selects spendable notes (largest-first) summing to *exactly*
1059    /// `remaining_amount`, or `None` if the inventory can't make exact change.
1060    /// Reads the DB but does not modify it. Single source of truth for the free
1061    /// (hand-out-existing-notes) selection shared by [`Self::send_ecash_dbtx`]
1062    /// and [`Self::can_make_exact_change`].
1063    async fn select_exact_change(
1064        dbtx: &mut DatabaseTransaction<'_>,
1065        mut remaining_amount: Amount,
1066    ) -> Option<Vec<SpendableNote>> {
1067        let mut stream = dbtx
1068            .find_by_prefix_sorted_descending(&SpendableNotePrefix)
1069            .await
1070            .map(|entry| entry.0.0);
1071
1072        let mut notes = vec![];
1073
1074        while let Some(spendable_note) = stream.next().await {
1075            remaining_amount = match remaining_amount.checked_sub(spendable_note.amount()) {
1076                Some(amount) => amount,
1077                None => continue,
1078            };
1079
1080            notes.push(spendable_note);
1081
1082            if remaining_amount == Amount::ZERO {
1083                break;
1084            }
1085        }
1086
1087        (remaining_amount == Amount::ZERO).then_some(notes)
1088    }
1089
1090    /// Await the final state of the receive operation.
1091    pub async fn await_final_receive_operation_state(
1092        &self,
1093        operation_id: OperationId,
1094    ) -> anyhow::Result<FinalReceiveOperationState> {
1095        let operation = self.client_ctx.get_operation(operation_id).await?;
1096        let mut stream = self.notifier.subscribe(operation_id).await;
1097
1098        let mut stream = self
1099            .client_ctx
1100            .outcome_or_updates(operation, operation_id, move || {
1101                async_stream::stream! {
1102                    loop {
1103                        if let Some(MintClientStateMachines::Receive(state)) = stream.next().await {
1104                            match state.state {
1105                                ReceiveSMState::Pending => {}
1106                                ReceiveSMState::Success => {
1107                                    yield FinalReceiveOperationState::Success;
1108                                    return;
1109                                }
1110                                ReceiveSMState::Rejected(..) => {
1111                                    yield FinalReceiveOperationState::Rejected;
1112                                    return;
1113                                }
1114                            }
1115                        }
1116                    }
1117                }
1118            })
1119            .into_stream();
1120
1121        let mut final_state = None;
1122
1123        while let Some(state) = stream.next().await {
1124            final_state = Some(state);
1125        }
1126
1127        Ok(final_state.expect("Stream contains one final state"))
1128    }
1129
1130    async fn remove_spendable_note(
1131        &self,
1132        dbtx: &mut DatabaseTransaction<'_>,
1133        spendable_note: &SpendableNote,
1134    ) {
1135        dbtx.remove_entry(&SpendableNoteKey(spendable_note.clone()))
1136            .await
1137            .expect("Must delete existing spendable note");
1138    }
1139}
1140
1141#[derive(Clone)]
1142struct PeerSelector {
1143    latency: Arc<RwLock<BTreeMap<PeerId, Duration>>>,
1144}
1145
1146impl PeerSelector {
1147    fn new(peers: BTreeSet<PeerId>) -> Self {
1148        let latency = peers
1149            .into_iter()
1150            .map(|peer| (peer, Duration::ZERO))
1151            .collect();
1152
1153        Self {
1154            latency: Arc::new(RwLock::new(latency)),
1155        }
1156    }
1157
1158    /// Pick 2 peers at random, return the one with lower latency
1159    fn choose_peer(&self) -> PeerId {
1160        let latency = self.latency.read().unwrap();
1161
1162        let peer_a = latency.iter().choose(&mut thread_rng()).unwrap();
1163        let peer_b = latency.iter().choose(&mut thread_rng()).unwrap();
1164
1165        if peer_a.1 <= peer_b.1 {
1166            *peer_a.0
1167        } else {
1168            *peer_b.0
1169        }
1170    }
1171
1172    // Update with exponential moving average (α = 0.1)
1173    fn report(&self, peer: PeerId, duration: Duration) {
1174        self.latency
1175            .write()
1176            .unwrap()
1177            .entry(peer)
1178            .and_modify(|latency| *latency = *latency * 9 / 10 + duration * 1 / 10)
1179            .or_insert(duration);
1180    }
1181
1182    fn remove(&self, peer: PeerId) {
1183        self.latency.write().unwrap().remove(&peer);
1184    }
1185}
1186
1187/// Download a slice with hash verification and peer selection
1188async fn download_slice_with_hash(
1189    module_api: DynModuleApi,
1190    peer_selector: PeerSelector,
1191    start: u64,
1192    end: u64,
1193    expected_hash: sha256::Hash,
1194) -> Vec<RecoveryItem> {
1195    const TIMEOUT: Duration = Duration::from_secs(30);
1196
1197    loop {
1198        let peer = peer_selector.choose_peer();
1199        let start_time = fedimint_core::time::now();
1200
1201        if let Ok(data) = module_api
1202            .fetch_recovery_slice(peer, TIMEOUT, start, end)
1203            .await
1204        {
1205            let elapsed = fedimint_core::time::now()
1206                .duration_since(start_time)
1207                .unwrap_or_default();
1208
1209            peer_selector.report(peer, elapsed);
1210
1211            if data.consensus_hash::<sha256::Hash>() == expected_hash {
1212                return data;
1213            }
1214
1215            peer_selector.remove(peer);
1216        } else {
1217            peer_selector.report(peer, TIMEOUT);
1218        }
1219    }
1220}
1221
1222#[derive(Error, Debug, Clone, Eq, PartialEq)]
1223pub enum SendECashError {
1224    #[error("We need to reissue notes but the client is offline")]
1225    Offline,
1226    #[error("The clients balance is insufficient")]
1227    InsufficientBalance,
1228    #[error("A non-recoverable error has occurred")]
1229    Failure,
1230}
1231
1232#[derive(Error, Debug, Clone, Eq, PartialEq)]
1233pub enum ReceiveECashError {
1234    #[error("The ECash is from a different federation")]
1235    WrongFederation,
1236    #[error("ECash contains an uneconomical denomination")]
1237    UneconomicalDenomination,
1238    #[error("Receiving ecash requires additional funds")]
1239    InsufficientFunds,
1240}
1241
1242#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1243pub enum FinalReceiveOperationState {
1244    // The ecash notes have been reissued
1245    Success,
1246    // The ecash notes were already spent
1247    Rejected,
1248}
1249
1250#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
1251pub enum MintClientStateMachines {
1252    Input(InputStateMachine),
1253    Output(MintOutputStateMachine),
1254    Receive(ReceiveStateMachine),
1255}
1256
1257impl IntoDynInstance for MintClientStateMachines {
1258    type DynType = DynState;
1259
1260    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
1261        DynState::from_typed(instance_id, self)
1262    }
1263}
1264
1265impl State for MintClientStateMachines {
1266    type ModuleContext = MintClientContext;
1267
1268    fn transitions(
1269        &self,
1270        context: &Self::ModuleContext,
1271        global_context: &DynGlobalClientContext,
1272    ) -> Vec<StateTransition<Self>> {
1273        match self {
1274            MintClientStateMachines::Input(redemption_state) => {
1275                sm_enum_variant_translation!(
1276                    redemption_state.transitions(context, global_context),
1277                    MintClientStateMachines::Input
1278                )
1279            }
1280            MintClientStateMachines::Output(issuance_state) => {
1281                sm_enum_variant_translation!(
1282                    issuance_state.transitions(context, global_context),
1283                    MintClientStateMachines::Output
1284                )
1285            }
1286            MintClientStateMachines::Receive(receive_state) => {
1287                sm_enum_variant_translation!(
1288                    receive_state.transitions(context, global_context),
1289                    MintClientStateMachines::Receive
1290                )
1291            }
1292        }
1293    }
1294
1295    fn operation_id(&self) -> OperationId {
1296        match self {
1297            MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
1298            MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
1299            MintClientStateMachines::Receive(receive_state) => receive_state.operation_id(),
1300        }
1301    }
1302}
1303
1304fn round_to_multiple(amount: Amount, min_denomiation: Amount) -> Amount {
1305    Amount::from_msats(amount.msats.next_multiple_of(min_denomiation.msats))
1306}
1307
1308fn represent_amount_with_fees(
1309    mut remaining_amount: Amount,
1310    fee_consensus: &FeeConsensus,
1311) -> Vec<Denomination> {
1312    let mut denominations = Vec::new();
1313
1314    // Add denominations with a greedy algorithm
1315    for denomination in client_denominations().rev() {
1316        let n_add =
1317            remaining_amount / (denomination.amount() + fee_consensus.fee(denomination.amount()));
1318
1319        denominations.extend(std::iter::repeat_n(denomination, n_add as usize));
1320
1321        remaining_amount -=
1322            n_add * (denomination.amount() + fee_consensus.fee(denomination.amount()));
1323    }
1324
1325    // We sort the notes by amount to minimize the leaked information.
1326    denominations.sort();
1327
1328    denominations
1329}
1330
1331fn represent_amount(mut remaining_amount: Amount) -> Vec<Denomination> {
1332    let mut denominations = Vec::new();
1333
1334    // Add denominations with a greedy algorithm
1335    for denomination in client_denominations().rev() {
1336        let n_add = remaining_amount / denomination.amount();
1337
1338        denominations.extend(std::iter::repeat_n(denomination, n_add as usize));
1339
1340        remaining_amount -= n_add * denomination.amount();
1341    }
1342
1343    denominations
1344}