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