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::error::{
40 InsufficientBalanceError, OperationLookupError, TransactionSubmitError,
41};
42use fedimint_client_module::module::init::{
43 ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs,
44 ClientModuleRecoveryPrepareArgs, RecoveryMode,
45};
46use fedimint_client_module::module::recovery::{NoModuleBackup, RecoveryProgress};
47use fedimint_client_module::module::{
48 ClientContext, OutPointRange, PrimaryModulePriority, PrimaryModuleSupport,
49};
50use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
51use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
52use fedimint_core::base32::{self, FEDIMINT_PREFIX};
53use fedimint_core::config::FederationId;
54use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
55use fedimint_core::db::{DatabaseTransaction, DatabaseVersion, IDatabaseTransactionOpsCoreTyped};
56use fedimint_core::encoding::{Decodable, Encodable};
57use fedimint_core::module::{
58 AmountUnit, Amounts, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
59};
60use fedimint_core::secp256k1::rand::{Rng, thread_rng};
61use fedimint_core::secp256k1::{Keypair, PublicKey};
62use fedimint_core::util::backoff_util::custom_backoff;
63use fedimint_core::util::{BoxStream, NextOrPending};
64use fedimint_core::{Amount, OutPoint, PeerId, apply, async_trait_maybe_send};
65use fedimint_derive_secret::DerivableSecret;
66use fedimint_mintv2_common::config::{FeeConsensus, MintClientConfig, client_denominations};
67use fedimint_mintv2_common::{
68 Denomination, KIND, MintCommonInit, MintInput, MintModuleTypes, MintOutput, Note, RecoveryItem,
69};
70use futures::{StreamExt, pin_mut};
71use itertools::Itertools;
72use serde::{Deserialize, Serialize};
73use serde_json::Value;
74use tbs::AggregatePublicKey;
75use thiserror::Error;
76
77use crate::api::MintV2ModuleApi;
78use crate::client_db::SpendableNoteKey;
79pub use crate::ecash::ECash;
80use crate::input::{InputSMCommon, InputSMState, InputStateMachine};
81use crate::issuance::NoteIssuanceRequest;
82use crate::output::{MintOutputStateMachine, OutputSMCommon, OutputSMState};
83use crate::receive::{ReceiveSMState, ReceiveStateMachine};
84
85const TARGET_PER_DENOMINATION: usize = 3;
86const SLICE_SIZE: u64 = 10000;
87const PEER_READMISSION: Duration = Duration::from_secs(60);
89const SLICE_TIMEOUT: Duration = Duration::from_secs(10);
97const MAX_SLICE_TIMEOUT: Duration = Duration::from_secs(30);
100const PARALLEL_HASH_REQUESTS: usize = 10;
101const PARALLEL_SLICE_REQUESTS: usize = 10;
102
103#[derive(Debug, Clone, PartialEq, Eq, Hash, Encodable, Decodable)]
104pub struct SpendableNote {
105 pub denomination: Denomination,
106 pub keypair: Keypair,
107 pub signature: tbs::Signature,
108}
109
110impl SpendableNote {
111 pub fn amount(&self) -> Amount {
112 self.denomination.amount()
113 }
114}
115
116impl SpendableNote {
117 fn nonce(&self) -> PublicKey {
118 self.keypair.public_key()
119 }
120
121 fn note(&self) -> Note {
122 Note {
123 denomination: self.denomination,
124 nonce: self.nonce(),
125 signature: self.signature,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub enum MintOperationMeta {
132 Send {
133 ecash: String,
134 custom_meta: Value,
135 },
136 Reissue {
137 change_outpoint_range: OutPointRange,
138 amount: Amount,
139 custom_meta: Value,
140 },
141 Receive {
142 change_outpoint_range: OutPointRange,
143 ecash: String,
144 custom_meta: Value,
145 },
146}
147
148#[derive(Debug, Clone)]
149pub struct MintClientInit;
150
151impl ModuleInit for MintClientInit {
152 type Common = MintCommonInit;
153
154 async fn dump_database(
155 &self,
156 _dbtx: &mut DatabaseTransaction<'_>,
157 _prefix_names: Vec<String>,
158 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
159 Box::new(BTreeMap::new().into_iter())
160 }
161}
162
163#[apply(async_trait_maybe_send!)]
164impl ClientModuleInit for MintClientInit {
165 type Module = MintClientModule;
166
167 fn supported_api_versions(&self) -> MultiApiVersion {
168 MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 1 }])
169 .expect("no version conflicts")
170 }
171
172 fn recovery_mode(&self) -> RecoveryMode {
173 RecoveryMode::Usable
174 }
175
176 async fn prepare_recovery(&self, args: &ClientModuleRecoveryPrepareArgs) -> anyhow::Result<()> {
177 if args
178 .db()
179 .begin_transaction_nc()
180 .await
181 .get_value(&RecoveryStateKey)
182 .await
183 .is_some()
184 {
185 return Ok(());
186 }
187
188 let state = RecoveryState {
195 next_index: 0,
196 total_items: args.module_api().fetch_recovery_count().await?,
197 requests: BTreeMap::new(),
198 nonces: BTreeSet::new(),
199 };
200
201 let mut dbtx = args.db().begin_transaction().await;
202
203 dbtx.insert_entry(&RecoveryStateKey, &state).await;
204
205 dbtx.commit_tx().await;
206
207 Ok(())
208 }
209
210 async fn recover(
211 &self,
212 args: &ClientModuleRecoverArgs<Self>,
213 _snapshot: Option<&NoModuleBackup>,
214 ) -> anyhow::Result<Option<Amount>> {
215 let mut state = args
216 .db()
217 .begin_transaction_nc()
218 .await
219 .get_value(&RecoveryStateKey)
220 .await
221 .expect("Prepare recovery commits the state before the recovery is started");
222
223 if state.next_index == state.total_items {
224 return Ok(None);
225 }
226
227 let peer_pool = PeerPool::new(args.api().all_peers());
228
229 let mut recovery_stream = futures::stream::iter(
230 (state.next_index..state.total_items).step_by(SLICE_SIZE as usize),
231 )
232 .map(|start| {
233 let api = args.module_api().clone();
234 let end = std::cmp::min(start + SLICE_SIZE, state.total_items);
235
236 async move { (start, end, api.fetch_recovery_slice_hash(start, end).await) }
237 })
238 .buffered(PARALLEL_HASH_REQUESTS)
239 .map(|(start, end, hash)| {
240 let module_api = args.module_api().clone();
241 let peer_pool = peer_pool.clone();
242
243 async move {
244 (
245 start,
246 download_slice(module_api, peer_pool, start, end, hash).await,
247 )
248 }
249 })
250 .buffer_unordered(PARALLEL_SLICE_REQUESTS);
256
257 let tweak_filter = issuance::tweak_filter(args.module_root_secret());
258
259 let mut pending: BTreeMap<u64, Vec<RecoveryItem>> = BTreeMap::new();
264
265 loop {
266 let items = loop {
267 if let Some(items) = pending.remove(&state.next_index) {
268 break items;
269 }
270
271 let (start, items) = recovery_stream
272 .next()
273 .await
274 .context("Recovery stream finished before recovery is complete")?;
275
276 pending.insert(start, items);
277 };
278
279 for item in &items {
280 match item {
281 RecoveryItem::Output {
282 denomination,
283 nonce_hash,
284 tweak,
285 } => {
286 if !issuance::check_tweak(*tweak, tweak_filter) {
287 continue;
288 }
289 let output_secret = issuance::output_secret(
290 *denomination,
291 *tweak,
292 args.module_root_secret(),
293 );
294
295 if !issuance::check_nonce(&output_secret, *nonce_hash) {
296 continue;
297 }
298
299 let computed_nonce_hash = issuance::nonce(&output_secret).consensus_hash();
300
301 if !state.nonces.insert(computed_nonce_hash) {
303 continue;
304 }
305
306 state.requests.insert(
307 computed_nonce_hash,
308 NoteIssuanceRequest::new(
309 *denomination,
310 *tweak,
311 args.module_root_secret(),
312 ),
313 );
314 }
315 RecoveryItem::Input { nonce_hash } => {
316 state.requests.remove(nonce_hash);
317 state.nonces.remove(nonce_hash);
318 }
319 }
320 }
321
322 state.next_index += items.len() as u64;
323
324 let mut dbtx = args.db().begin_transaction().await;
325
326 dbtx.insert_entry(&RecoveryStateKey, &state).await;
327
328 if state.next_index == state.total_items {
329 let recovered_amount = state
331 .requests
332 .values()
333 .map(|request| request.denomination.amount())
334 .sum::<Amount>();
335
336 let state_machines = args
337 .context()
338 .map_dyn(vec![MintClientStateMachines::Output(
339 MintOutputStateMachine {
340 common: OutputSMCommon {
341 operation_id: OperationId::new_random(),
342 range: None,
343 issuance_requests: state.requests.into_values().collect(),
344 },
345 state: OutputSMState::Pending,
346 },
347 )])
348 .collect();
349
350 args.context()
351 .add_state_machines_dbtx(&mut dbtx.to_ref_nc(), state_machines)
352 .await
353 .expect("state machine is valid");
354
355 dbtx.commit_tx().await;
356
357 return Ok(Some(recovered_amount));
358 }
359
360 dbtx.commit_tx().await;
361
362 args.update_recovery_progress(RecoveryProgress {
363 complete: state.next_index.try_into().unwrap_or(u32::MAX),
364 total: state.total_items.try_into().unwrap_or(u32::MAX),
365 });
366 }
367 }
368
369 async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
370 let (tweak_sender, tweak_receiver) = async_channel::bounded(50);
371
372 let filter = issuance::tweak_filter(args.module_root_secret());
373
374 fedimint_core::task::spawn("mintv2-tweak-grinder", async move {
381 loop {
382 let tweak: [u8; 16] = thread_rng().r#gen();
383
384 if !issuance::check_tweak(tweak, filter) {
385 continue;
386 }
387
388 if tweak_sender.send(tweak).await.is_err() {
389 return;
390 }
391
392 fedimint_core::task::sleep(Duration::ZERO).await;
393 }
394 });
395
396 Ok(MintClientModule {
397 federation_id: *args.federation_id(),
398 cfg: args.cfg().clone(),
399 root_secret: args.module_root_secret().clone(),
400 notifier: args.notifier().clone(),
401 client_ctx: args.context(),
402 balance_update_sender: tokio::sync::watch::channel(()).0,
403 tweak_receiver,
404 })
405 }
406
407 fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
408 BTreeMap::new()
409 }
410}
411
412#[derive(Debug)]
413pub struct MintClientModule {
414 federation_id: FederationId,
415 cfg: MintClientConfig,
416 root_secret: DerivableSecret,
417 notifier: ModuleNotifier<MintClientStateMachines>,
418 client_ctx: ClientContext<Self>,
419 balance_update_sender: tokio::sync::watch::Sender<()>,
420 tweak_receiver: async_channel::Receiver<[u8; 16]>,
421}
422
423#[derive(Debug, Clone)]
424pub struct MintClientContext {
425 client_ctx: ClientContext<MintClientModule>,
426 tbs_agg_pks: BTreeMap<Denomination, AggregatePublicKey>,
427 tbs_pks: BTreeMap<Denomination, BTreeMap<PeerId, tbs::PublicKeyShare>>,
428 pub balance_update_sender: tokio::sync::watch::Sender<()>,
429}
430
431impl Context for MintClientContext {
432 const KIND: Option<ModuleKind> = Some(KIND);
433}
434
435#[apply(async_trait_maybe_send!)]
436impl ClientModule for MintClientModule {
437 type Init = MintClientInit;
438 type Common = MintModuleTypes;
439 type Backup = NoModuleBackup;
440 type ModuleStateMachineContext = MintClientContext;
441 type States = MintClientStateMachines;
442
443 fn context(&self) -> Self::ModuleStateMachineContext {
444 MintClientContext {
445 client_ctx: self.client_ctx.clone(),
446 tbs_agg_pks: self.cfg.tbs_agg_pks.clone(),
447 tbs_pks: self.cfg.tbs_pks.clone(),
448 balance_update_sender: self.balance_update_sender.clone(),
449 }
450 }
451
452 fn input_fee(
453 &self,
454 amounts: &Amounts,
455 _input: &<Self::Common as ModuleCommon>::Input,
456 ) -> Option<Amounts> {
457 let unit = self.cfg.amount_unit;
458 let amount = amounts.get(&unit).copied().unwrap_or_default();
459 let fee = self.cfg.fee_consensus.fee(amount);
460
461 Some(Amounts::new_custom(unit, fee))
462 }
463
464 fn output_fee(
465 &self,
466 amounts: &Amounts,
467 _output: &<Self::Common as ModuleCommon>::Output,
468 ) -> Option<Amounts> {
469 let unit = self.cfg.amount_unit;
470 let amount = amounts.get(&unit).copied().unwrap_or_default();
471 let fee = self.cfg.fee_consensus.fee(amount);
472
473 Some(Amounts::new_custom(unit, fee))
474 }
475
476 #[cfg(feature = "cli")]
477 async fn handle_cli_command(
478 &self,
479 args: &[std::ffi::OsString],
480 ) -> anyhow::Result<serde_json::Value> {
481 cli::handle_cli_command(self, args).await
482 }
483
484 fn supports_being_primary(&self) -> PrimaryModuleSupport {
485 PrimaryModuleSupport::selected(PrimaryModulePriority::HIGH, [self.cfg.amount_unit])
486 }
487
488 async fn create_final_inputs_and_outputs(
489 &self,
490 dbtx: &mut DatabaseTransaction<'_>,
491 operation_id: OperationId,
492 unit: AmountUnit,
493 mut input_amount: Amount,
494 mut output_amount: Amount,
495 ) -> anyhow::Result<(
496 ClientInputBundle<MintInput, MintClientStateMachines>,
497 ClientOutputBundle<MintOutput, MintClientStateMachines>,
498 )> {
499 if unit != self.cfg.amount_unit {
500 anyhow::bail!("Module can only handle its configured amount unit");
501 }
502
503 let requested_amount = output_amount.saturating_sub(input_amount);
504 let Some(funding_notes) = self.select_funding_input(dbtx, requested_amount).await else {
507 let total_amount = self.get_balance(dbtx, unit).await;
508 return Err(InsufficientBalanceError {
509 requested_amount,
510 total_amount,
511 }
512 .into());
513 };
514
515 for note in &funding_notes {
516 self.remove_spendable_note(dbtx, note).await;
517 }
518
519 input_amount += funding_notes.iter().map(SpendableNote::amount).sum();
520
521 output_amount += funding_notes
522 .iter()
523 .map(|input| self.cfg.fee_consensus.fee(input.amount()))
524 .sum();
525
526 assert!(output_amount <= input_amount);
527
528 let (input_notes, output_amounts) = self
529 .rebalance(dbtx, &self.cfg.fee_consensus, input_amount - output_amount)
530 .await;
531
532 for note in &input_notes {
533 self.remove_spendable_note(dbtx, note).await;
534 }
535
536 input_amount += input_notes.iter().map(SpendableNote::amount).sum();
537
538 output_amount += input_notes
539 .iter()
540 .map(|note| self.cfg.fee_consensus.fee(note.amount()))
541 .sum();
542
543 output_amount += output_amounts
544 .iter()
545 .map(|denomination| {
546 denomination.amount() + self.cfg.fee_consensus.fee(denomination.amount())
547 })
548 .sum();
549
550 assert!(output_amount <= input_amount);
551
552 let mut spendable_notes = funding_notes
553 .into_iter()
554 .chain(input_notes)
555 .collect::<Vec<SpendableNote>>();
556
557 spendable_notes.sort_by_key(|note| note.denomination);
559
560 let input_bundle =
561 Self::create_input_bundle(operation_id, spendable_notes, false, self.cfg.amount_unit);
562
563 let mut denominations = represent_amount_with_fees(
564 input_amount.saturating_sub(output_amount),
565 &self.cfg.fee_consensus,
566 )
567 .into_iter()
568 .chain(output_amounts)
569 .collect::<Vec<Denomination>>();
570
571 denominations.sort();
573
574 let output_bundle = self.create_output_bundle(operation_id, denominations).await;
575
576 let sender = self.balance_update_sender.clone();
577 dbtx.on_commit(move || sender.send_replace(()));
578
579 Ok((input_bundle, output_bundle))
580 }
581
582 async fn await_primary_module_output(
583 &self,
584 operation_id: OperationId,
585 outpoint: OutPoint,
586 ) -> anyhow::Result<()> {
587 self.await_output_sm_success(operation_id, outpoint).await
588 }
589
590 async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
591 if unit != self.cfg.amount_unit {
592 return Amount::ZERO;
593 }
594
595 self.get_count_by_denomination_dbtx(dbtx)
596 .await
597 .into_iter()
598 .map(|(denomination, count)| denomination.amount().mul_u64(count))
599 .sum()
600 }
601
602 async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
603 Box::pin(tokio_stream::wrappers::WatchStream::new(
604 self.balance_update_sender.subscribe(),
605 ))
606 }
607}
608
609impl MintClientModule {
610 async fn select_funding_input(
611 &self,
612 dbtx: &mut DatabaseTransaction<'_>,
613 mut excess_output: Amount,
614 ) -> Option<Vec<SpendableNote>> {
615 let mut selected_notes = Vec::new();
616 let mut target_notes = Vec::new();
617 let mut excess_notes = Vec::new();
618
619 for amount in client_denominations().rev() {
620 let notes_amount = dbtx
621 .find_by_prefix(&SpendableNoteAmountPrefix(amount))
622 .await
623 .map(|entry| entry.0.0)
624 .collect::<Vec<SpendableNote>>()
625 .await;
626
627 target_notes.extend(notes_amount.iter().take(TARGET_PER_DENOMINATION).cloned());
628
629 if notes_amount.len() > 2 * TARGET_PER_DENOMINATION {
630 for note in notes_amount.into_iter().skip(TARGET_PER_DENOMINATION) {
631 let note_fee = self.cfg.fee_consensus.fee(note.amount());
632
633 let note_value = note
634 .amount()
635 .checked_sub(note_fee)
636 .expect("All our notes are economical");
637
638 excess_output = excess_output.saturating_sub(note_value);
639
640 selected_notes.push(note);
641 }
642 } else {
643 excess_notes.extend(notes_amount.into_iter().skip(TARGET_PER_DENOMINATION));
644 }
645 }
646
647 if excess_output == Amount::ZERO {
648 return Some(selected_notes);
649 }
650
651 for note in excess_notes.into_iter().chain(target_notes) {
652 let note_amount = note.amount();
653 let note_value = note_amount
654 .checked_sub(self.cfg.fee_consensus.fee(note_amount))
655 .expect("All our notes are economical");
656
657 excess_output = excess_output.saturating_sub(note_value);
658
659 selected_notes.push(note);
660
661 if excess_output == Amount::ZERO {
662 return Some(selected_notes);
663 }
664 }
665
666 None
667 }
668
669 async fn rebalance(
670 &self,
671 dbtx: &mut DatabaseTransaction<'_>,
672 fee: &FeeConsensus,
673 mut excess_input: Amount,
674 ) -> (Vec<SpendableNote>, Vec<Denomination>) {
675 let n_denominations = self.get_count_by_denomination_dbtx(dbtx).await;
676
677 let mut notes = dbtx
678 .find_by_prefix_sorted_descending(&SpendableNotePrefix)
679 .await
680 .map(|entry| entry.0.0)
681 .fuse();
682
683 let mut input_notes = Vec::new();
684 let mut output_denominations = Vec::new();
685
686 for d in client_denominations() {
687 let n_denomination = n_denominations.get(&d).copied().unwrap_or(0);
688
689 let n_missing = TARGET_PER_DENOMINATION.saturating_sub(n_denomination as usize);
690
691 for _ in 0..n_missing {
692 match excess_input.checked_sub(d.amount() + fee.fee(d.amount())) {
693 Some(remaining_excess) => excess_input = remaining_excess,
694 None => match notes.next().await {
695 Some(note) => {
696 if note.amount() <= d.amount() + fee.fee(d.amount()) {
697 break;
698 }
699
700 excess_input += note.amount() - (d.amount() + fee.fee(d.amount()));
701
702 input_notes.push(note);
703 }
704 None => break,
705 },
706 }
707
708 output_denominations.push(d);
709 }
710 }
711
712 (input_notes, output_denominations)
713 }
714
715 fn create_input_bundle(
716 operation_id: OperationId,
717 notes: Vec<SpendableNote>,
718 include_receive_sm: bool,
719 amount_unit: AmountUnit,
720 ) -> ClientInputBundle<MintInput, MintClientStateMachines> {
721 let inputs = notes
722 .iter()
723 .map(|spendable_note| ClientInput {
724 input: MintInput::new_v0(spendable_note.note()),
725 keys: vec![spendable_note.keypair],
726 amounts: Amounts::new_custom(amount_unit, spendable_note.amount()),
727 })
728 .collect();
729
730 let input_sms = vec![ClientInputSM {
731 state_machines: Arc::new(move |range: OutPointRange| {
732 let mut sms = vec![MintClientStateMachines::Input(InputStateMachine {
733 common: InputSMCommon {
734 operation_id,
735 txid: range.txid(),
736 spendable_notes: notes.clone(),
737 },
738 state: InputSMState::Pending,
739 })];
740
741 if include_receive_sm {
742 sms.push(MintClientStateMachines::Receive(ReceiveStateMachine {
743 common: crate::receive::ReceiveSMCommon {
744 operation_id,
745 txid: range.txid(),
746 },
747 state: crate::receive::ReceiveSMState::Pending,
748 }));
749 }
750
751 sms
752 }),
753 }];
754
755 ClientInputBundle::new(inputs, input_sms)
756 }
757
758 async fn create_output_bundle(
769 &self,
770 operation_id: OperationId,
771 requested_denominations: Vec<Denomination>,
772 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
773 let issuance_requests = futures::stream::iter(requested_denominations)
774 .zip(self.tweak_receiver.clone())
775 .map(|(d, tweak)| NoteIssuanceRequest::new(d, tweak, &self.root_secret))
776 .collect::<Vec<NoteIssuanceRequest>>()
777 .await;
778
779 let amount_unit = self.cfg.amount_unit;
780 let outputs = issuance_requests
781 .iter()
782 .map(|request| ClientOutput {
783 output: request.output(),
784 amounts: Amounts::new_custom(amount_unit, request.denomination.amount()),
785 })
786 .collect();
787
788 let output_sms = vec![ClientOutputSM {
789 state_machines: Arc::new(move |range: OutPointRange| {
790 vec![MintClientStateMachines::Output(MintOutputStateMachine {
791 common: OutputSMCommon {
792 operation_id,
793 range: Some(range),
794 issuance_requests: issuance_requests.clone(),
795 },
796 state: OutputSMState::Pending,
797 })]
798 }),
799 }];
800
801 ClientOutputBundle::new(outputs, output_sms)
802 }
803
804 async fn await_output_sm_success(
814 &self,
815 operation_id: OperationId,
816 outpoint: OutPoint,
817 ) -> anyhow::Result<()> {
818 let stream = self
819 .notifier
820 .subscribe(operation_id)
821 .await
822 .filter_map(|state| async {
823 let MintClientStateMachines::Output(state) = state else {
824 return None;
825 };
826
827 if !state.common.range?.into_iter().contains(&outpoint) {
828 return None;
829 }
830
831 match state.state {
832 OutputSMState::Pending => None,
833 OutputSMState::Success => Some(Ok(())),
834 OutputSMState::Aborted => Some(Err(anyhow!("Transaction was rejected"))),
835 OutputSMState::Failure => Some(Err(anyhow!("Failed to finalize notes",))),
836 }
837 });
838
839 pin_mut!(stream);
840
841 stream.next_or_pending().await
842 }
843
844 pub async fn get_count_by_denomination(&self) -> BTreeMap<Denomination, u64> {
846 self.get_count_by_denomination_dbtx(
847 &mut self.client_ctx.module_db().begin_transaction_nc().await,
848 )
849 .await
850 }
851
852 async fn get_count_by_denomination_dbtx(
853 &self,
854 dbtx: &mut DatabaseTransaction<'_>,
855 ) -> BTreeMap<Denomination, u64> {
856 dbtx.find_by_prefix(&SpendableNotePrefix)
857 .await
858 .fold(BTreeMap::new(), |mut acc, entry| async move {
859 acc.entry(entry.0.0.denomination)
860 .and_modify(|count| *count += 1)
861 .or_insert(1);
862
863 acc
864 })
865 .await
866 }
867
868 pub async fn send(
894 &self,
895 amount: Amount,
896 custom_meta: Value,
897 include_invite: bool,
898 ) -> Result<(OperationId, ECash), SendECashError> {
899 let amount = round_to_multiple(amount, client_denominations().next().unwrap().amount());
900
901 if let Some((operation_id, ecash)) = self
902 .client_ctx
903 .module_db()
904 .autocommit(
905 |dbtx, _| {
906 Box::pin(self.send_ecash_dbtx(
907 dbtx,
908 amount,
909 custom_meta.clone(),
910 include_invite,
911 ))
912 },
913 Some(100),
914 )
915 .await
916 .expect("Failed to commit dbtx after 100 retries")
917 {
918 return Ok((operation_id, ecash));
919 }
920
921 self.client_ctx
922 .global_api()
923 .session_count()
924 .await
925 .map_err(|_| SendECashError::Offline)?;
926
927 let operation_id = OperationId::new_random();
928
929 let output = self
930 .create_output_bundle(operation_id, represent_amount(amount))
931 .await;
932 let output = self.client_ctx.make_client_outputs(output);
933 let cm = custom_meta.clone();
934
935 let range = self
936 .client_ctx
937 .finalize_and_submit_transaction(
938 operation_id,
939 MintCommonInit::KIND.as_str(),
940 move |change_outpoint_range| MintOperationMeta::Reissue {
941 change_outpoint_range,
942 amount,
943 custom_meta: cm.clone(),
944 },
945 TransactionBuilder::new().with_outputs(output),
946 )
947 .await
948 .map_err(|error| match error {
949 TransactionSubmitError::InsufficientFunds(_) => SendECashError::InsufficientBalance,
950 other => SendECashError::Failed(other),
951 })?;
952
953 for outpoint in range {
954 self.await_output_sm_success(operation_id, outpoint)
955 .await
956 .map_err(|_| SendECashError::Failure)?;
957 }
958
959 Box::pin(self.send(amount, custom_meta, include_invite)).await
960 }
961
962 async fn send_ecash_dbtx(
973 &self,
974 dbtx: &mut DatabaseTransaction<'_>,
975 remaining_amount: Amount,
976 custom_meta: Value,
977 include_invite: bool,
978 ) -> Result<Option<(OperationId, ECash)>, Infallible> {
979 let Some(notes) = Self::select_exact_change(&mut dbtx.to_ref_nc(), remaining_amount).await
980 else {
981 return Ok(None);
982 };
983
984 for spendable_note in ¬es {
985 self.remove_spendable_note(dbtx, spendable_note).await;
986 }
987
988 let ecash = if include_invite {
989 let invite = self.client_ctx.get_invite_code().await;
990 ECash::new_with_invite(notes, &invite)
991 } else {
992 ECash::new(self.federation_id, notes)
993 }
994 .with_unit(self.cfg.amount_unit);
995 let amount = ecash.amount();
996 let operation_id = OperationId::new_random();
997
998 self.client_ctx
999 .add_operation_log_entry_dbtx(
1000 dbtx,
1001 operation_id,
1002 MintCommonInit::KIND.as_str(),
1003 MintOperationMeta::Send {
1004 ecash: base32::encode_prefixed(FEDIMINT_PREFIX, &ecash),
1005 custom_meta,
1006 },
1007 )
1008 .await;
1009
1010 self.client_ctx
1011 .log_event(
1012 dbtx,
1013 SendPaymentEvent {
1014 operation_id,
1015 amount,
1016 ecash: base32::encode_prefixed(FEDIMINT_PREFIX, &ecash),
1017 },
1018 )
1019 .await;
1020
1021 let sender = self.balance_update_sender.clone();
1022 dbtx.on_commit(move || sender.send_replace(()));
1023
1024 Ok(Some((operation_id, ecash)))
1025 }
1026
1027 pub async fn receive(
1029 &self,
1030 ecash: ECash,
1031 custom_meta: Value,
1032 ) -> Result<OperationId, ReceiveECashError> {
1033 let operation_id = OperationId::from_encodable(&ecash);
1034
1035 if ecash.mint() != Some(self.federation_id) {
1036 return Err(ReceiveECashError::WrongFederation);
1037 }
1038
1039 if ecash
1040 .notes()
1041 .iter()
1042 .any(|note| note.amount() <= self.cfg.fee_consensus.base_fee())
1043 {
1044 return Err(ReceiveECashError::UneconomicalDenomination);
1045 }
1046
1047 let input =
1048 Self::create_input_bundle(operation_id, ecash.notes(), true, self.cfg.amount_unit);
1049 let input = self.client_ctx.make_client_inputs(input);
1050 let ec = base32::encode_prefixed(FEDIMINT_PREFIX, &ecash);
1051
1052 self.client_ctx
1053 .finalize_and_submit_transaction(
1054 operation_id,
1055 MintCommonInit::KIND.as_str(),
1056 move |change_outpoint_range| MintOperationMeta::Receive {
1057 change_outpoint_range,
1058 ecash: ec.clone(),
1059 custom_meta: custom_meta.clone(),
1060 },
1061 TransactionBuilder::new().with_inputs(input),
1062 )
1063 .await
1064 .map_err(|error| match error {
1065 TransactionSubmitError::OperationAlreadyExists(_) => {
1066 ReceiveECashError::AlreadyReceived
1067 }
1068 TransactionSubmitError::InsufficientFunds(_) => {
1069 ReceiveECashError::InsufficientFunds
1070 }
1071 other => ReceiveECashError::Failed(other),
1072 })?;
1073
1074 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1075
1076 self.client_ctx
1077 .log_event(
1078 &mut dbtx,
1079 ReceivePaymentEvent {
1080 operation_id,
1081 amount: ecash.amount(),
1082 },
1083 )
1084 .await;
1085
1086 dbtx.commit_tx().await;
1087
1088 Ok(operation_id)
1089 }
1090
1091 pub async fn receive_fee_quote(
1101 &self,
1102 ecash: &ECash,
1103 ) -> Result<FeeQuote, TransactionSubmitError> {
1104 let notes = ecash.notes();
1108 let input_amount: Amount = notes.iter().map(SpendableNote::amount).sum();
1109 let input_fee: Amount = notes
1110 .iter()
1111 .map(|note| self.cfg.fee_consensus.fee(note.amount()))
1112 .sum();
1113
1114 self.client_ctx
1115 .fee_quote(
1116 OperationId::new_random(),
1117 FeeQuoteRequest {
1118 input_amount: Amounts::new_custom(self.cfg.amount_unit, input_amount),
1119 output_amount: Amounts::ZERO,
1120 input_fee: Amounts::new_custom(self.cfg.amount_unit, input_fee),
1121 output_fee: Amounts::ZERO,
1122 },
1123 )
1124 .await
1125 }
1126
1127 pub async fn send_fee_quote(&self, amount: Amount) -> Result<FeeQuote, TransactionSubmitError> {
1141 let amount = round_to_multiple(amount, client_denominations().next().unwrap().amount());
1142
1143 if self.can_make_exact_change(amount).await {
1145 return Ok(FeeQuote::ZERO);
1146 }
1147
1148 let denominations = represent_amount(amount);
1152 let output_amount: Amount = denominations.iter().map(|d| d.amount()).sum();
1153 let output_fee: Amount = denominations
1154 .iter()
1155 .map(|d| self.cfg.fee_consensus.fee(d.amount()))
1156 .sum();
1157
1158 self.client_ctx
1159 .fee_quote(
1160 OperationId::new_random(),
1161 FeeQuoteRequest {
1162 input_amount: Amounts::ZERO,
1163 output_amount: Amounts::new_custom(self.cfg.amount_unit, output_amount),
1164 input_fee: Amounts::ZERO,
1165 output_fee: Amounts::new_custom(self.cfg.amount_unit, output_fee),
1166 },
1167 )
1168 .await
1169 }
1170
1171 async fn can_make_exact_change(&self, remaining_amount: Amount) -> bool {
1176 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1177
1178 Self::select_exact_change(&mut dbtx, remaining_amount)
1179 .await
1180 .is_some()
1181 }
1182
1183 async fn select_exact_change(
1189 dbtx: &mut DatabaseTransaction<'_>,
1190 mut remaining_amount: Amount,
1191 ) -> Option<Vec<SpendableNote>> {
1192 let mut stream = dbtx
1193 .find_by_prefix_sorted_descending(&SpendableNotePrefix)
1194 .await
1195 .map(|entry| entry.0.0);
1196
1197 let mut notes = vec![];
1198
1199 while let Some(spendable_note) = stream.next().await {
1200 remaining_amount = match remaining_amount.checked_sub(spendable_note.amount()) {
1201 Some(amount) => amount,
1202 None => continue,
1203 };
1204
1205 notes.push(spendable_note);
1206
1207 if remaining_amount == Amount::ZERO {
1208 break;
1209 }
1210 }
1211
1212 (remaining_amount == Amount::ZERO).then_some(notes)
1213 }
1214
1215 pub async fn await_final_receive_operation_state(
1217 &self,
1218 operation_id: OperationId,
1219 ) -> Result<FinalReceiveOperationState, OperationLookupError> {
1220 let operation = self.client_ctx.get_operation(operation_id).await?;
1221 let mut stream = self.notifier.subscribe(operation_id).await;
1222
1223 let mut stream = self
1224 .client_ctx
1225 .outcome_or_updates(&operation, operation_id, |_| true, move || {
1226 async_stream::stream! {
1227 loop {
1228 if let Some(MintClientStateMachines::Receive(state)) = stream.next().await {
1229 match state.state {
1230 ReceiveSMState::Pending => {}
1231 ReceiveSMState::Success => {
1232 yield FinalReceiveOperationState::Success;
1233 return;
1234 }
1235 ReceiveSMState::Rejected(..) => {
1236 yield FinalReceiveOperationState::Rejected;
1237 return;
1238 }
1239 }
1240 }
1241 }
1242 }
1243 })
1244 .into_stream();
1245
1246 let mut final_state = None;
1247
1248 while let Some(state) = stream.next().await {
1249 final_state = Some(state);
1250 }
1251
1252 Ok(final_state.expect("Stream contains one final state"))
1253 }
1254
1255 async fn remove_spendable_note(
1256 &self,
1257 dbtx: &mut DatabaseTransaction<'_>,
1258 spendable_note: &SpendableNote,
1259 ) {
1260 dbtx.remove_entry(&SpendableNoteKey(spendable_note.clone()))
1261 .await
1262 .expect("Must delete existing spendable note");
1263 }
1264}
1265
1266#[derive(Clone)]
1273struct PeerPool {
1274 receiver: async_channel::Receiver<PeerId>,
1275 sender: async_channel::Sender<PeerId>,
1276}
1277
1278impl PeerPool {
1279 fn new(peers: &BTreeSet<PeerId>) -> Self {
1280 let (sender, receiver) = async_channel::bounded(peers.len().max(1));
1281
1282 for peer in peers {
1283 sender
1284 .try_send(*peer)
1285 .expect("Capacity was sized to hold every peer");
1286 }
1287
1288 Self { receiver, sender }
1289 }
1290
1291 async fn acquire(&self) -> PeerId {
1293 self.receiver
1294 .recv()
1295 .await
1296 .expect("The sender is held for as long as the receiver")
1297 }
1298
1299 fn retire(&self, peer: PeerId) {
1306 let pool = self.clone();
1307
1308 fedimint_core::runtime::spawn("mintv2 recovery peer readmission", async move {
1309 fedimint_core::runtime::sleep(PEER_READMISSION).await;
1310
1311 pool.release(peer);
1312 });
1313 }
1314
1315 fn release(&self, peer: PeerId) {
1317 self.sender
1318 .try_send(peer)
1319 .expect("Only peers taken from the pool are put back");
1320 }
1321}
1322
1323async fn download_slice(
1326 module_api: DynModuleApi,
1327 peers: PeerPool,
1328 start: u64,
1329 end: u64,
1330 expected_hash: sha256::Hash,
1331) -> Vec<RecoveryItem> {
1332 let mut timeouts = custom_backoff(SLICE_TIMEOUT, MAX_SLICE_TIMEOUT, None);
1333
1334 loop {
1335 let peer = peers.acquire().await;
1336
1337 let timeout = timeouts.next().expect("The backoff never gives up");
1338
1339 let result = module_api
1340 .fetch_recovery_slice(peer, timeout, start, end)
1341 .await;
1342
1343 match result {
1344 Ok(data) if data.consensus_hash::<sha256::Hash>() == expected_hash => {
1345 peers.release(peer);
1346
1347 return data;
1348 }
1349 Ok(_) | Err(_) => peers.retire(peer),
1356 }
1357 }
1358}
1359
1360#[derive(Error, Debug)]
1362#[non_exhaustive]
1363pub enum SendECashError {
1364 #[error("We need to reissue notes but the client is offline")]
1367 Offline,
1368 #[error("The clients balance is insufficient")]
1370 InsufficientBalance,
1371 #[error("A non-recoverable error has occurred")]
1374 Failure,
1375 #[error("The reissue transaction could not be submitted")]
1378 Failed(#[source] TransactionSubmitError),
1379}
1380
1381#[derive(Error, Debug)]
1383#[non_exhaustive]
1384pub enum ReceiveECashError {
1385 #[error("The ECash is from a different federation")]
1387 WrongFederation,
1388
1389 #[error("ECash contains an uneconomical denomination")]
1391 UneconomicalDenomination,
1392
1393 #[error("Receiving ecash requires additional funds")]
1395 InsufficientFunds,
1396
1397 #[error("The ECash was already received")]
1400 AlreadyReceived,
1401
1402 #[error("The reissue transaction could not be submitted")]
1405 Failed(#[source] TransactionSubmitError),
1406}
1407
1408#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1409pub enum FinalReceiveOperationState {
1410 Success,
1412 Rejected,
1414}
1415
1416#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
1417pub enum MintClientStateMachines {
1418 Input(InputStateMachine),
1419 Output(MintOutputStateMachine),
1420 Receive(ReceiveStateMachine),
1421}
1422
1423impl IntoDynInstance for MintClientStateMachines {
1424 type DynType = DynState;
1425
1426 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
1427 DynState::from_typed(instance_id, self)
1428 }
1429}
1430
1431impl State for MintClientStateMachines {
1432 type ModuleContext = MintClientContext;
1433
1434 fn transitions(
1435 &self,
1436 context: &Self::ModuleContext,
1437 global_context: &DynGlobalClientContext,
1438 ) -> Vec<StateTransition<Self>> {
1439 match self {
1440 MintClientStateMachines::Input(redemption_state) => {
1441 sm_enum_variant_translation!(
1442 redemption_state.transitions(context, global_context),
1443 MintClientStateMachines::Input
1444 )
1445 }
1446 MintClientStateMachines::Output(issuance_state) => {
1447 sm_enum_variant_translation!(
1448 issuance_state.transitions(context, global_context),
1449 MintClientStateMachines::Output
1450 )
1451 }
1452 MintClientStateMachines::Receive(receive_state) => {
1453 sm_enum_variant_translation!(
1454 receive_state.transitions(context, global_context),
1455 MintClientStateMachines::Receive
1456 )
1457 }
1458 }
1459 }
1460
1461 fn operation_id(&self) -> OperationId {
1462 match self {
1463 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
1464 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
1465 MintClientStateMachines::Receive(receive_state) => receive_state.operation_id(),
1466 }
1467 }
1468}
1469
1470fn round_to_multiple(amount: Amount, min_denomiation: Amount) -> Amount {
1471 Amount::from_msats(amount.msats.next_multiple_of(min_denomiation.msats))
1472}
1473
1474fn represent_amount_with_fees(
1475 mut remaining_amount: Amount,
1476 fee_consensus: &FeeConsensus,
1477) -> Vec<Denomination> {
1478 let mut denominations = Vec::new();
1479
1480 for denomination in client_denominations().rev() {
1482 let n_add =
1483 remaining_amount / (denomination.amount() + fee_consensus.fee(denomination.amount()));
1484
1485 denominations.extend(std::iter::repeat_n(denomination, n_add as usize));
1486
1487 remaining_amount -=
1488 n_add * (denomination.amount() + fee_consensus.fee(denomination.amount()));
1489 }
1490
1491 denominations.sort();
1493
1494 denominations
1495}
1496
1497fn represent_amount(mut remaining_amount: Amount) -> Vec<Denomination> {
1498 let mut denominations = Vec::new();
1499
1500 for denomination in client_denominations().rev() {
1502 let n_add = remaining_amount / denomination.amount();
1503
1504 denominations.extend(std::iter::repeat_n(denomination, n_add as usize));
1505
1506 remaining_amount -= n_add * denomination.amount();
1507 }
1508
1509 denominations
1510}