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;
84const PEER_READMISSION: Duration = Duration::from_secs(60);
86const SLICE_TIMEOUT: Duration = Duration::from_secs(10);
94const 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 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 .buffer_unordered(PARALLEL_SLICE_REQUESTS);
253
254 let tweak_filter = issuance::tweak_filter(args.module_root_secret());
255
256 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 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 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 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 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 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 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 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 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 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 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 ¬es {
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 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 pub async fn receive_fee_quote(&self, ecash: &ECash) -> anyhow::Result<FeeQuote> {
1089 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 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 if self.can_make_exact_change(amount).await {
1130 return Ok(FeeQuote::ZERO);
1131 }
1132
1133 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 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 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 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#[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 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 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 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
1308async 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 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 Success,
1371 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 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 denominations.sort();
1452
1453 denominations
1454}
1455
1456fn represent_amount(mut remaining_amount: Amount) -> Vec<Denomination> {
1457 let mut denominations = Vec::new();
1458
1459 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}