1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_truncation)]
3#![allow(clippy::missing_errors_doc)]
4#![allow(clippy::missing_panics_doc)]
5#![allow(clippy::module_name_repetitions)]
6#![allow(clippy::must_use_candidate)]
7#![allow(clippy::return_self_not_must_use)]
8
9#[cfg(feature = "uniffi")]
10::uniffi::setup_scaffolding!();
11
12pub mod backup;
14#[cfg(feature = "cli")]
16mod cli;
17pub mod client_db;
19#[cfg(feature = "uniffi")]
21pub mod ffi;
22mod input;
24mod oob;
26pub mod output;
28
29pub mod events;
30
31pub mod api;
33
34pub mod repair_wallet;
35
36pub mod visualize;
37
38use std::cmp::{Ordering, min};
39use std::collections::{BTreeMap, BTreeSet};
40use std::fmt;
41use std::fmt::{Display, Formatter};
42use std::io::Read;
43use std::str::FromStr;
44use std::sync::{Arc, RwLock};
45use std::time::Duration;
46
47use anyhow::{Context as _, anyhow, bail, ensure};
48use api::MintFederationApi;
49use async_stream::{stream, try_stream};
50use backup::recovery::{MintRecovery, RecoveryStateV2};
51use base64::Engine as _;
52use bitcoin_hashes::{Hash, HashEngine as BitcoinHashEngine, sha256, sha256t};
53use client_db::{
54 DbKeyPrefix, NoteKeyPrefix, RecoveryFinalizedKey, RecoveryStateKey, RecoveryStateV2Key,
55 ReusedNoteIndices, migrate_state_to_v2, migrate_to_v1,
56};
57use events::{NoteSpent, OOBNotesReissued, OOBNotesSpent, ReceivePaymentEvent, SendPaymentEvent};
58use fedimint_api_client::api::DynModuleApi;
59use fedimint_client_module::db::{ClientModuleMigrationFn, migrate_state};
60use fedimint_client_module::module::init::{
61 ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs, RecoveryMode,
62};
63use fedimint_client_module::module::recovery::RecoveryProgress;
64use fedimint_client_module::module::{
65 ClientContext, ClientModule, IClientModule, OutPointRange, PrimaryModulePriority,
66 PrimaryModuleSupport,
67};
68use fedimint_client_module::oplog::{OperationLogEntry, UpdateStreamOrOutcome};
69use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
70use fedimint_client_module::transaction::{
71 ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
72 ClientOutputSM, FeeQuote, FeeQuoteRequest, TransactionBuilder,
73};
74use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
75use fedimint_core::base32::{FEDIMINT_PREFIX, encode_prefixed};
76use fedimint_core::config::{FederationId, FederationIdPrefix};
77use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
78use fedimint_core::db::{
79 AutocommitError, Database, DatabaseTransaction, DatabaseVersion,
80 IDatabaseTransactionOpsCoreTyped,
81};
82use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
83use fedimint_core::invite_code::InviteCode;
84use fedimint_core::module::registry::{ModuleDecoderRegistry, ModuleRegistry};
85use fedimint_core::module::{
86 AmountUnit, Amounts, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
87};
88use fedimint_core::secp256k1::rand::prelude::IteratorRandom;
89use fedimint_core::secp256k1::rand::thread_rng;
90use fedimint_core::secp256k1::{All, Keypair, Secp256k1};
91use fedimint_core::util::{BoxFuture, BoxStream, NextOrPending, SafeUrl};
92use fedimint_core::{
93 Amount, IdxRange, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId, apply,
94 async_trait_maybe_send, base32, push_db_pair_items,
95};
96use fedimint_derive_secret::{ChildId, DerivableSecret};
97use fedimint_logging::LOG_CLIENT_MODULE_MINT;
98pub use fedimint_mint_common as common;
99use fedimint_mint_common::config::{FeeConsensus, MintClientConfig};
100pub use fedimint_mint_common::*;
101use futures::future::try_join_all;
102use futures::{StreamExt, pin_mut};
103use hex::ToHex;
104use input::MintInputStateCreatedBundle;
105use itertools::Itertools as _;
106use output::MintOutputStatesCreatedMulti;
107use serde::{Deserialize, Serialize};
108use strum::IntoEnumIterator;
109use tbs::AggregatePublicKey;
110use thiserror::Error;
111use tracing::{debug, warn};
112
113use crate::backup::EcashBackup;
114use crate::client_db::{
115 CancelledOOBSpendKey, CancelledOOBSpendKeyPrefix, NextECashNoteIndexKey,
116 NextECashNoteIndexKeyPrefix, NoteKey,
117};
118use crate::input::{MintInputCommon, MintInputStateMachine, MintInputStates};
119use crate::oob::{MintOOBStateMachine, MintOOBStates, MintOOBStatesCreatedMulti};
120use crate::output::{
121 MintOutputCommon, MintOutputStateMachine, MintOutputStates, NoteIssuanceRequest,
122};
123
124const MINT_E_CASH_TYPE_CHILD_ID: ChildId = ChildId(0);
125
126const OOB_SPEND_NO_TIMEOUT: Duration = Duration::MAX;
127
128#[derive(Clone)]
129struct PeerSelector {
130 latency: Arc<RwLock<BTreeMap<PeerId, Duration>>>,
131}
132
133impl PeerSelector {
134 fn new(peers: BTreeSet<PeerId>) -> Self {
135 let latency = peers
136 .into_iter()
137 .map(|peer| (peer, Duration::ZERO))
138 .collect();
139
140 Self {
141 latency: Arc::new(RwLock::new(latency)),
142 }
143 }
144
145 fn choose_peer(&self) -> PeerId {
146 let latency = self.latency.read().expect("poisoned");
147
148 let peer_a = latency.iter().choose(&mut thread_rng()).expect("no peers");
149 let peer_b = latency.iter().choose(&mut thread_rng()).expect("no peers");
150
151 if peer_a.1 <= peer_b.1 {
152 *peer_a.0
153 } else {
154 *peer_b.0
155 }
156 }
157
158 fn report(&self, peer: PeerId, duration: Duration) {
159 self.latency
160 .write()
161 .expect("poisoned")
162 .entry(peer)
163 .and_modify(|latency| *latency = *latency * 9 / 10 + duration / 10)
164 .or_insert(duration);
165 }
166
167 fn remove(&self, peer: PeerId) {
168 self.latency.write().expect("poisoned").remove(&peer);
169 }
170}
171
172async fn download_slice_with_hash(
174 module_api: DynModuleApi,
175 peer_selector: PeerSelector,
176 start: u64,
177 end: u64,
178 expected_hash: sha256::Hash,
179) -> Vec<RecoveryItem> {
180 const TIMEOUT: Duration = Duration::from_secs(30);
181
182 loop {
183 let peer = peer_selector.choose_peer();
184 let start_time = fedimint_core::time::now();
185
186 match tokio::time::timeout(TIMEOUT, module_api.fetch_recovery_slice(peer, start, end))
187 .await
188 .map_err(Into::into)
189 .and_then(|r| r)
190 {
191 Ok(data) => {
192 let elapsed = fedimint_core::time::now()
193 .duration_since(start_time)
194 .unwrap_or(Duration::ZERO);
195
196 peer_selector.report(peer, elapsed);
197
198 if data.consensus_hash::<sha256::Hash>() == expected_hash {
199 return data;
200 }
201
202 peer_selector.remove(peer);
203 }
204 Err(..) => {
205 peer_selector.report(peer, TIMEOUT);
206 }
207 }
208 }
209}
210
211#[derive(Clone, Debug, Encodable, PartialEq, Eq)]
219pub struct OOBNotes(Vec<OOBNotesPart>);
220
221#[cfg(feature = "uniffi")]
222uniffi::custom_type!(OOBNotes, String, {
223 lower: |n| n.to_string(),
224 try_lift: |s| OOBNotes::from_str(&s),
225});
226
227#[derive(Clone, Debug, Decodable, Encodable, PartialEq, Eq)]
230enum OOBNotesPart {
231 Notes(TieredMulti<SpendableNote>),
232 FederationIdPrefix(FederationIdPrefix),
233 Invite {
237 peer_apis: Vec<(PeerId, SafeUrl)>,
239 federation_id: FederationId,
240 },
241 ApiSecret(String),
242 #[encodable_default]
243 Default {
244 variant: u64,
245 bytes: Vec<u8>,
246 },
247}
248
249impl OOBNotes {
250 pub fn new(
251 federation_id_prefix: FederationIdPrefix,
252 notes: TieredMulti<SpendableNote>,
253 ) -> Self {
254 Self(vec![
255 OOBNotesPart::FederationIdPrefix(federation_id_prefix),
256 OOBNotesPart::Notes(notes),
257 ])
258 }
259
260 pub fn new_with_invite(notes: TieredMulti<SpendableNote>, invite: &InviteCode) -> Self {
261 let mut data = vec![
262 OOBNotesPart::FederationIdPrefix(invite.federation_id().to_prefix()),
265 OOBNotesPart::Notes(notes),
266 OOBNotesPart::Invite {
267 peer_apis: vec![(invite.peer(), invite.url())],
268 federation_id: invite.federation_id(),
269 },
270 ];
271 if let Some(api_secret) = invite.api_secret() {
272 data.push(OOBNotesPart::ApiSecret(api_secret));
273 }
274 Self(data)
275 }
276
277 pub fn federation_id_prefix(&self) -> FederationIdPrefix {
278 self.0
279 .iter()
280 .find_map(|data| match data {
281 OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
282 OOBNotesPart::Invite { federation_id, .. } => Some(federation_id.to_prefix()),
283 _ => None,
284 })
285 .expect("Invariant violated: OOBNotes does not contain a FederationIdPrefix")
286 }
287
288 pub fn notes(&self) -> &TieredMulti<SpendableNote> {
289 self.0
290 .iter()
291 .find_map(|data| match data {
292 OOBNotesPart::Notes(notes) => Some(notes),
293 _ => None,
294 })
295 .expect("Invariant violated: OOBNotes does not contain any notes")
296 }
297
298 pub fn notes_json(&self) -> Result<serde_json::Value, serde_json::Error> {
299 let mut notes_map = serde_json::Map::new();
300 for notes in &self.0 {
301 match notes {
302 OOBNotesPart::Notes(notes) => {
303 let notes_json: serde_json::Map<String, serde_json::Value> = notes
304 .iter()
305 .map(|(amount, notes_vec)| {
306 let notes_with_nonce: Vec<serde_json::Value> = notes_vec
307 .iter()
308 .map(|note| {
309 serde_json::json!({
310 "signature": note.signature,
311 "spend_key": note.spend_key,
312 "nonce": note.nonce(),
313 })
314 })
315 .collect();
316 (
317 amount.msats.to_string(),
318 serde_json::Value::Array(notes_with_nonce),
319 )
320 })
321 .collect();
322 notes_map.insert("notes".to_string(), serde_json::Value::Object(notes_json));
323 }
324 OOBNotesPart::FederationIdPrefix(prefix) => {
325 notes_map.insert(
326 "federation_id_prefix".to_string(),
327 serde_json::to_value(prefix.to_string())?,
328 );
329 }
330 OOBNotesPart::Invite {
331 peer_apis,
332 federation_id,
333 } => {
334 let (peer_id, api) = peer_apis
335 .first()
336 .cloned()
337 .expect("Decoding makes sure peer_apis isn't empty");
338 notes_map.insert(
339 "invite".to_string(),
340 serde_json::to_value(InviteCode::new(
341 api,
342 peer_id,
343 *federation_id,
344 self.api_secret(),
345 ))?,
346 );
347 }
348 OOBNotesPart::ApiSecret(_) => { }
349 OOBNotesPart::Default { variant, bytes } => {
350 notes_map.insert(
351 format!("default_{variant}"),
352 serde_json::to_value(bytes.encode_hex::<String>())?,
353 );
354 }
355 }
356 }
357 Ok(serde_json::Value::Object(notes_map))
358 }
359
360 pub fn federation_invite(&self) -> Option<InviteCode> {
361 self.0.iter().find_map(|data| {
362 let OOBNotesPart::Invite {
363 peer_apis,
364 federation_id,
365 } = data
366 else {
367 return None;
368 };
369 let (peer_id, api) = peer_apis
370 .first()
371 .cloned()
372 .expect("Decoding makes sure peer_apis isn't empty");
373 Some(InviteCode::new(
374 api,
375 peer_id,
376 *federation_id,
377 self.api_secret(),
378 ))
379 })
380 }
381
382 fn api_secret(&self) -> Option<String> {
383 self.0.iter().find_map(|data| {
384 let OOBNotesPart::ApiSecret(api_secret) = data else {
385 return None;
386 };
387 Some(api_secret.clone())
388 })
389 }
390}
391
392impl Decodable for OOBNotes {
393 fn consensus_decode_partial<R: Read>(
394 r: &mut R,
395 _modules: &ModuleDecoderRegistry,
396 ) -> Result<Self, DecodeError> {
397 let inner =
398 Vec::<OOBNotesPart>::consensus_decode_partial(r, &ModuleDecoderRegistry::default())?;
399
400 if !inner
402 .iter()
403 .any(|data| matches!(data, OOBNotesPart::Notes(_)))
404 {
405 return Err(DecodeError::from_str(
406 "No e-cash notes were found in OOBNotes data",
407 ));
408 }
409
410 let maybe_federation_id_prefix = inner.iter().find_map(|data| match data {
411 OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
412 _ => None,
413 });
414
415 let maybe_invite = inner.iter().find_map(|data| match data {
416 OOBNotesPart::Invite {
417 federation_id,
418 peer_apis,
419 } => Some((federation_id, peer_apis)),
420 _ => None,
421 });
422
423 match (maybe_federation_id_prefix, maybe_invite) {
424 (Some(p), Some((ip, _))) => {
425 if p != ip.to_prefix() {
426 return Err(DecodeError::from_str(
427 "Inconsistent Federation ID provided in OOBNotes data",
428 ));
429 }
430 }
431 (None, None) => {
432 return Err(DecodeError::from_str(
433 "No Federation ID provided in OOBNotes data",
434 ));
435 }
436 _ => {}
437 }
438
439 if let Some((_, invite)) = maybe_invite
440 && invite.is_empty()
441 {
442 return Err(DecodeError::from_str("Invite didn't contain API endpoints"));
443 }
444
445 Ok(OOBNotes(inner))
446 }
447}
448
449const BASE64_URL_SAFE: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new(
450 &base64::alphabet::URL_SAFE,
451 base64::engine::general_purpose::PAD,
452);
453
454impl FromStr for OOBNotes {
455 type Err = anyhow::Error;
456
457 fn from_str(s: &str) -> Result<Self, Self::Err> {
459 let s: String = s.chars().filter(|&c| !c.is_whitespace()).collect();
460
461 let oob_notes_bytes = if let Ok(oob_notes_bytes) =
462 base32::decode_prefixed_bytes(FEDIMINT_PREFIX, &s)
463 {
464 oob_notes_bytes
465 } else if let Ok(oob_notes_bytes) = BASE64_URL_SAFE.decode(&s) {
466 oob_notes_bytes
467 } else if let Ok(oob_notes_bytes) = base64::engine::general_purpose::STANDARD.decode(&s) {
468 oob_notes_bytes
469 } else {
470 bail!("OOBNotes were not a well-formed base64(URL-safe) or base32 string");
471 };
472
473 let oob_notes =
474 OOBNotes::consensus_decode_whole(&oob_notes_bytes, &ModuleDecoderRegistry::default())?;
475
476 ensure!(!oob_notes.notes().is_empty(), "OOBNotes cannot be empty");
477
478 Ok(oob_notes)
479 }
480}
481
482impl Display for OOBNotes {
483 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
484 let bytes = Encodable::consensus_encode_to_vec(self);
485
486 f.write_str(&BASE64_URL_SAFE.encode(&bytes))
487 }
488}
489
490impl Serialize for OOBNotes {
491 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
492 where
493 S: serde::Serializer,
494 {
495 serializer.serialize_str(&self.to_string())
496 }
497}
498
499impl<'de> Deserialize<'de> for OOBNotes {
500 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
501 where
502 D: serde::Deserializer<'de>,
503 {
504 let s = String::deserialize(deserializer)?;
505 FromStr::from_str(&s).map_err(serde::de::Error::custom)
506 }
507}
508
509impl OOBNotes {
510 pub fn total_amount(&self) -> Amount {
512 self.notes().total_amount()
513 }
514}
515
516#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
519#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
520pub enum ReissueExternalNotesState {
521 Created,
524 Issuing,
527 Done,
529 Failed(String),
531}
532
533#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
536#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
537pub enum SpendOOBState {
538 Created,
540 UserCanceledProcessing,
543 UserCanceledSuccess,
546 UserCanceledFailure,
549 Success,
553 Refunded,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct MintOperationMeta {
561 pub variant: MintOperationMetaVariant,
562 pub amount: Amount,
563 pub extra_meta: serde_json::Value,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
567#[serde(rename_all = "snake_case")]
568pub enum MintOperationMetaVariant {
569 Reissuance {
573 #[serde(skip_serializing, default, rename = "out_point")]
575 legacy_out_point: Option<OutPoint>,
576 #[serde(default)]
578 txid: Option<TransactionId>,
579 #[serde(default)]
581 out_point_indices: Vec<u64>,
582 },
583 SpendOOB {
584 requested_amount: Amount,
585 oob_notes: OOBNotes,
586 #[serde(default)]
587 no_timeout: bool,
588 },
589}
590
591#[derive(Debug, Clone)]
592pub struct MintClientInit;
593
594const SLICE_SIZE: u64 = 10000;
595const PARALLEL_HASH_REQUESTS: usize = 10;
596const PARALLEL_SLICE_REQUESTS: usize = 10;
597
598impl MintClientInit {
599 #[allow(clippy::too_many_lines)]
600 async fn recover_from_slices(
601 &self,
602 args: &ClientModuleRecoverArgs<Self>,
603 ) -> anyhow::Result<Option<Amount>> {
604 let mut state = if let Some(state) = args
606 .db()
607 .begin_transaction_nc()
608 .await
609 .get_value(&RecoveryStateV2Key)
610 .await
611 {
612 state
613 } else {
614 let total_items = args.module_api().fetch_recovery_count().await?;
616
617 RecoveryStateV2::new(
618 total_items,
619 args.cfg().tbs_pks.tiers().copied().collect(),
620 args.module_root_secret(),
621 )
622 };
623
624 if state.next_index == state.total_items {
625 return Ok(None);
626 }
627
628 let peer_selector = PeerSelector::new(args.api().all_peers().clone());
629
630 let mut recovery_stream = futures::stream::iter(
631 (state.next_index..state.total_items).step_by(SLICE_SIZE as usize),
632 )
633 .map(move |start| {
634 let api = args.module_api().clone();
635 let end = std::cmp::min(start + SLICE_SIZE, state.total_items);
636
637 async move { (start, end, api.fetch_recovery_slice_hash(start, end).await) }
638 })
639 .buffered(PARALLEL_HASH_REQUESTS)
640 .map(move |(start, end, hash)| {
641 download_slice_with_hash(
642 args.module_api().clone(),
643 peer_selector.clone(),
644 start,
645 end,
646 hash,
647 )
648 })
649 .buffered(PARALLEL_SLICE_REQUESTS);
650
651 let secret = args.module_root_secret().clone();
652
653 loop {
654 let items = recovery_stream
655 .next()
656 .await
657 .expect("mint recovery stream finished before recovery is complete");
658
659 for item in &items {
660 match item {
661 RecoveryItem::Output { amount, nonce } => {
662 state.handle_output(*amount, *nonce, &secret);
663 }
664 RecoveryItem::Input { nonce } => {
665 state.handle_input(*nonce);
666 }
667 }
668 }
669
670 state.next_index += items.len() as u64;
671
672 let mut dbtx = args.db().begin_transaction().await;
673
674 dbtx.insert_entry(&RecoveryStateV2Key, &state).await;
675
676 if state.next_index == state.total_items {
677 let finalized = state.finalize();
679
680 let recovered_amount = finalized
682 .pending_notes
683 .iter()
684 .map(|(amount, _)| *amount)
685 .sum::<Amount>();
686
687 let blind_nonces: Vec<BlindNonce> = finalized
689 .pending_notes
690 .iter()
691 .map(|(_, req)| BlindNonce(req.blinded_message()))
692 .collect();
693
694 let outpoints = if blind_nonces.is_empty() {
696 vec![]
697 } else {
698 args.module_api()
699 .fetch_blind_nonce_outpoints(blind_nonces)
700 .await
701 .context("Failed to fetch blind nonce outpoints")?
702 };
703
704 let state_machines: Vec<MintClientStateMachines> = finalized
706 .pending_notes
707 .into_iter()
708 .zip(outpoints)
709 .map(|((amount, issuance_request), out_point)| {
710 MintClientStateMachines::Output(MintOutputStateMachine {
711 common: MintOutputCommon {
712 operation_id: OperationId::new_random(),
713 out_point_range: OutPointRange::new_single(
714 out_point.txid,
715 out_point.out_idx,
716 )
717 .expect("Can't overflow"),
718 },
719 state: MintOutputStates::Created(output::MintOutputStatesCreated {
720 amount,
721 issuance_request,
722 }),
723 })
724 })
725 .collect();
726
727 let state_machines = args.context().map_dyn(state_machines).collect();
728
729 args.context()
730 .add_state_machines_dbtx(&mut dbtx.to_ref_nc(), state_machines)
731 .await?;
732
733 for (amount, note_idx) in finalized.next_note_idx {
735 dbtx.insert_entry(&NextECashNoteIndexKey(amount), ¬e_idx.as_u64())
736 .await;
737 }
738
739 dbtx.commit_tx().await;
740
741 return Ok(Some(recovered_amount));
742 }
743
744 dbtx.commit_tx().await;
745
746 args.update_recovery_progress(RecoveryProgress {
747 complete: state.next_index.try_into().unwrap_or(u32::MAX),
748 total: state.total_items.try_into().unwrap_or(u32::MAX),
749 });
750 }
751 }
752}
753
754impl ModuleInit for MintClientInit {
755 type Common = MintCommonInit;
756
757 async fn dump_database(
758 &self,
759 dbtx: &mut DatabaseTransaction<'_>,
760 prefix_names: Vec<String>,
761 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
762 let mut mint_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
763 BTreeMap::new();
764 let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
765 prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
766 });
767
768 for table in filtered_prefixes {
769 match table {
770 DbKeyPrefix::Note => {
771 push_db_pair_items!(
772 dbtx,
773 NoteKeyPrefix,
774 NoteKey,
775 SpendableNoteUndecoded,
776 mint_client_items,
777 "Notes"
778 );
779 }
780 DbKeyPrefix::NextECashNoteIndex => {
781 push_db_pair_items!(
782 dbtx,
783 NextECashNoteIndexKeyPrefix,
784 NextECashNoteIndexKey,
785 u64,
786 mint_client_items,
787 "NextECashNoteIndex"
788 );
789 }
790 DbKeyPrefix::CancelledOOBSpend => {
791 push_db_pair_items!(
792 dbtx,
793 CancelledOOBSpendKeyPrefix,
794 CancelledOOBSpendKey,
795 (),
796 mint_client_items,
797 "CancelledOOBSpendKey"
798 );
799 }
800 DbKeyPrefix::RecoveryFinalized => {
801 if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await {
802 mint_client_items.insert("RecoveryFinalized".to_string(), Box::new(val));
803 }
804 }
805 DbKeyPrefix::RecoveryState
806 | DbKeyPrefix::ReusedNoteIndices
807 | DbKeyPrefix::RecoveryStateV2
808 | DbKeyPrefix::ExternalReservedStart
809 | DbKeyPrefix::CoreInternalReservedStart
810 | DbKeyPrefix::CoreInternalReservedEnd => {}
811 }
812 }
813
814 Box::new(mint_client_items.into_iter())
815 }
816}
817
818#[apply(async_trait_maybe_send!)]
819impl ClientModuleInit for MintClientInit {
820 type Module = MintClientModule;
821
822 fn supported_api_versions(&self) -> MultiApiVersion {
823 MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
824 .expect("no version conflicts")
825 }
826
827 async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
828 Ok(MintClientModule {
829 federation_id: *args.federation_id(),
830 cfg: args.cfg().clone(),
831 secret: args.module_root_secret().clone(),
832 secp: Secp256k1::new(),
833 notifier: args.notifier().clone(),
834 client_ctx: args.context(),
835 balance_update_sender: tokio::sync::watch::channel(()).0,
836 })
837 }
838
839 fn recovery_mode(&self) -> RecoveryMode {
840 RecoveryMode::Unusable
841 }
842
843 async fn recover(
844 &self,
845 args: &ClientModuleRecoverArgs<Self>,
846 snapshot: Option<&<Self::Module as ClientModule>::Backup>,
847 ) -> anyhow::Result<Option<Amount>> {
848 let mut dbtx = args.db().begin_transaction_nc().await;
849
850 if dbtx.get_value(&RecoveryStateV2Key).await.is_some() {
852 return self.recover_from_slices(args).await;
853 }
854
855 if dbtx.get_value(&RecoveryStateKey).await.is_some() {
857 return args
858 .recover_from_history::<MintRecovery>(self, snapshot)
859 .await;
860 }
861
862 if args.module_api().fetch_recovery_count().await.is_ok() {
865 self.recover_from_slices(args).await
867 } else {
868 args.recover_from_history::<MintRecovery>(self, snapshot)
870 .await
871 }
872 }
873
874 fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
875 let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = BTreeMap::new();
876 migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
877 Box::pin(migrate_to_v1(dbtx))
878 });
879 migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
880 Box::pin(async { migrate_state(active_states, inactive_states, migrate_state_to_v2) })
881 });
882
883 migrations
884 }
885
886 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
887 Some(
888 DbKeyPrefix::iter()
889 .map(|p| p as u8)
890 .chain(
891 DbKeyPrefix::ExternalReservedStart as u8
892 ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
893 )
894 .collect(),
895 )
896 }
897}
898
899#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
925pub struct MintClientModule {
926 federation_id: FederationId,
927 cfg: MintClientConfig,
928 secret: DerivableSecret,
929 secp: Secp256k1<All>,
930 notifier: ModuleNotifier<MintClientStateMachines>,
931 pub client_ctx: ClientContext<Self>,
932 balance_update_sender: tokio::sync::watch::Sender<()>,
933}
934
935impl fmt::Debug for MintClientModule {
936 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
937 f.debug_struct("MintClientModule")
938 .field("federation_id", &self.federation_id)
939 .field("cfg", &self.cfg)
940 .field("notifier", &self.notifier)
941 .field("client_ctx", &self.client_ctx)
942 .finish_non_exhaustive()
943 }
944}
945
946#[derive(Clone)]
948pub struct MintClientContext {
949 pub federation_id: FederationId,
950 pub client_ctx: ClientContext<MintClientModule>,
951 pub mint_decoder: Decoder,
952 pub tbs_pks: Tiered<AggregatePublicKey>,
953 pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>,
954 pub secret: DerivableSecret,
955 pub module_db: Database,
958 pub balance_update_sender: tokio::sync::watch::Sender<()>,
960}
961
962impl fmt::Debug for MintClientContext {
963 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
964 f.debug_struct("MintClientContext")
965 .field("federation_id", &self.federation_id)
966 .finish_non_exhaustive()
967 }
968}
969
970impl MintClientContext {
971 fn await_cancel_oob_payment(&self, operation_id: OperationId) -> BoxFuture<'static, ()> {
972 let db = self.module_db.clone();
973 Box::pin(async move {
974 db.wait_key_exists(&CancelledOOBSpendKey(operation_id))
975 .await;
976 })
977 }
978}
979
980impl Context for MintClientContext {
981 const KIND: Option<ModuleKind> = Some(KIND);
982}
983
984#[apply(async_trait_maybe_send!)]
985impl ClientModule for MintClientModule {
986 type Init = MintClientInit;
987 type Common = MintModuleTypes;
988 type Backup = EcashBackup;
989 type ModuleStateMachineContext = MintClientContext;
990 type States = MintClientStateMachines;
991
992 fn context(&self) -> Self::ModuleStateMachineContext {
993 MintClientContext {
994 federation_id: self.federation_id,
995 client_ctx: self.client_ctx.clone(),
996 mint_decoder: self.decoder(),
997 tbs_pks: self.cfg.tbs_pks.clone(),
998 peer_tbs_pks: self.cfg.peer_tbs_pks.clone(),
999 secret: self.secret.clone(),
1000 module_db: self.client_ctx.module_db().clone(),
1001 balance_update_sender: self.balance_update_sender.clone(),
1002 }
1003 }
1004
1005 fn input_fee(
1006 &self,
1007 amount: &Amounts,
1008 _input: &<Self::Common as ModuleCommon>::Input,
1009 ) -> Option<Amounts> {
1010 Some(Amounts::new_bitcoin(
1011 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
1012 ))
1013 }
1014
1015 fn output_fee(
1016 &self,
1017 amount: &Amounts,
1018 _output: &<Self::Common as ModuleCommon>::Output,
1019 ) -> Option<Amounts> {
1020 Some(Amounts::new_bitcoin(
1021 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
1022 ))
1023 }
1024
1025 #[cfg(feature = "cli")]
1026 async fn handle_cli_command(
1027 &self,
1028 args: &[std::ffi::OsString],
1029 ) -> anyhow::Result<serde_json::Value> {
1030 cli::handle_cli_command(self, args).await
1031 }
1032
1033 fn supports_backup(&self) -> bool {
1034 true
1035 }
1036
1037 async fn backup(&self) -> anyhow::Result<EcashBackup> {
1038 self.client_ctx
1039 .module_db()
1040 .autocommit(
1041 |dbtx_ctx, _| {
1042 Box::pin(async { self.prepare_plaintext_ecash_backup(dbtx_ctx).await })
1043 },
1044 None,
1045 )
1046 .await
1047 .map_err(|e| match e {
1048 AutocommitError::ClosureError { error, .. } => error,
1049 AutocommitError::CommitFailed { last_error, .. } => {
1050 anyhow!("Commit to DB failed: {last_error}")
1051 }
1052 })
1053 }
1054
1055 fn supports_being_primary(&self) -> PrimaryModuleSupport {
1056 PrimaryModuleSupport::selected(PrimaryModulePriority::HIGH, [AmountUnit::BITCOIN])
1057 }
1058
1059 async fn create_final_inputs_and_outputs(
1060 &self,
1061 dbtx: &mut DatabaseTransaction<'_>,
1062 operation_id: OperationId,
1063 unit: AmountUnit,
1064 mut input_amount: Amount,
1065 mut output_amount: Amount,
1066 ) -> anyhow::Result<(
1067 ClientInputBundle<MintInput, MintClientStateMachines>,
1068 ClientOutputBundle<MintOutput, MintClientStateMachines>,
1069 )> {
1070 let consolidation_inputs = self.consolidate_notes(dbtx).await?;
1071
1072 if unit != AmountUnit::BITCOIN {
1073 bail!("Module can only handle Bitcoin");
1074 }
1075
1076 input_amount += consolidation_inputs
1077 .iter()
1078 .map(|input| input.0.amounts.get_bitcoin())
1079 .sum();
1080
1081 output_amount += consolidation_inputs
1082 .iter()
1083 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
1084 .sum();
1085
1086 let additional_inputs = self
1087 .create_sufficient_input(dbtx, output_amount.saturating_sub(input_amount))
1088 .await?;
1089
1090 input_amount += additional_inputs
1091 .iter()
1092 .map(|input| input.0.amounts.get_bitcoin())
1093 .sum();
1094
1095 output_amount += additional_inputs
1096 .iter()
1097 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
1098 .sum();
1099
1100 let outputs = self
1101 .create_output(
1102 dbtx,
1103 operation_id,
1104 2,
1105 input_amount.saturating_sub(output_amount),
1106 )
1107 .await;
1108
1109 Ok((
1110 create_bundle_for_inputs(
1111 [consolidation_inputs, additional_inputs].concat(),
1112 operation_id,
1113 ),
1114 outputs,
1115 ))
1116 }
1117
1118 async fn await_primary_module_output(
1119 &self,
1120 operation_id: OperationId,
1121 out_point: OutPoint,
1122 ) -> anyhow::Result<()> {
1123 self.await_output_finalized(operation_id, out_point).await
1124 }
1125
1126 async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
1127 if unit != AmountUnit::BITCOIN {
1128 return Amount::ZERO;
1129 }
1130 self.get_note_counts_by_denomination(dbtx)
1131 .await
1132 .total_amount()
1133 }
1134
1135 async fn get_balances(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
1136 Amounts::new_bitcoin(
1137 <Self as ClientModule>::get_balance(self, dbtx, AmountUnit::BITCOIN).await,
1138 )
1139 }
1140
1141 async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
1142 Box::pin(tokio_stream::wrappers::WatchStream::new(
1143 self.balance_update_sender.subscribe(),
1144 ))
1145 }
1146
1147 async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
1148 let balance = ClientModule::get_balances(self, dbtx).await;
1149
1150 for (unit, amount) in balance {
1151 if Amount::from_units(0) < amount {
1152 bail!("Outstanding balance: {amount}, unit: {unit:?}");
1153 }
1154 }
1155
1156 if !self.client_ctx.get_own_active_states().await.is_empty() {
1157 bail!("Pending operations")
1158 }
1159 Ok(())
1160 }
1161
1162 async fn handle_rpc(
1163 &self,
1164 method: String,
1165 request: serde_json::Value,
1166 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
1167 Box::pin(try_stream! {
1168 match method.as_str() {
1169 "reissue_external_notes" => {
1170 let req: ReissueExternalNotesRequest = serde_json::from_value(request)?;
1171 let result = self.reissue_external_notes(req.oob_notes, req.extra_meta).await?;
1172 yield serde_json::to_value(result)?;
1173 }
1174 "subscribe_reissue_external_notes" => {
1175 let req: SubscribeReissueExternalNotesRequest = serde_json::from_value(request)?;
1176 let stream = self.subscribe_reissue_external_notes(req.operation_id).await?;
1177 for await state in stream.into_stream() {
1178 yield serde_json::to_value(state)?;
1179 }
1180 }
1181 "spend_notes" => {
1182 let req: SpendNotesRequest = serde_json::from_value(request)?;
1183 let result = self.spend_notes_with_selector(
1184 &SelectNotesWithExactAmount,
1185 req.amount,
1186 req.try_cancel_after,
1187 req.include_invite,
1188 req.extra_meta
1189 ).await?;
1190 yield serde_json::to_value(result)?;
1191 }
1192 "spend_notes_expert" => {
1193 let req: SpendNotesExpertRequest = serde_json::from_value(request)?;
1194 let result = self.spend_notes_with_selector(
1195 &SelectNotesWithAtleastAmount,
1196 req.min_amount,
1197 req.try_cancel_after,
1198 req.include_invite,
1199 req.extra_meta
1200 ).await?;
1201 yield serde_json::to_value(result)?;
1202 }
1203 "validate_notes" => {
1204 let req: ValidateNotesRequest = serde_json::from_value(request)?;
1205 let result = self.validate_notes(&req.oob_notes)?;
1206 yield serde_json::to_value(result)?;
1207 }
1208 "try_cancel_spend_notes" => {
1209 let req: TryCancelSpendNotesRequest = serde_json::from_value(request)?;
1210 let result = self.try_cancel_spend_notes(req.operation_id).await;
1211 yield serde_json::to_value(result)?;
1212 }
1213 "subscribe_spend_notes" => {
1214 let req: SubscribeSpendNotesRequest = serde_json::from_value(request)?;
1215 let stream = self.subscribe_spend_notes(req.operation_id).await?;
1216 for await state in stream.into_stream() {
1217 yield serde_json::to_value(state)?;
1218 }
1219 }
1220 "await_spend_oob_refund" => {
1221 let req: AwaitSpendOobRefundRequest = serde_json::from_value(request)?;
1222 let value = self.await_spend_oob_refund(req.operation_id).await;
1223 yield serde_json::to_value(value)?;
1224 }
1225 "note_counts_by_denomination" => {
1226 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1227 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
1228 yield serde_json::to_value(note_counts)?;
1229 }
1230 _ => {
1231 Err(anyhow::format_err!("Unknown method: {method}"))?;
1232 unreachable!()
1233 },
1234 }
1235 })
1236 }
1237}
1238
1239#[derive(Deserialize)]
1240struct ReissueExternalNotesRequest {
1241 oob_notes: OOBNotes,
1242 extra_meta: serde_json::Value,
1243}
1244
1245#[derive(Deserialize)]
1246struct SubscribeReissueExternalNotesRequest {
1247 operation_id: OperationId,
1248}
1249
1250#[derive(Deserialize)]
1253struct SpendNotesExpertRequest {
1254 min_amount: Amount,
1255 try_cancel_after: Option<Duration>,
1256 include_invite: bool,
1257 extra_meta: serde_json::Value,
1258}
1259
1260#[derive(Deserialize)]
1261struct SpendNotesRequest {
1262 amount: Amount,
1263 try_cancel_after: Option<Duration>,
1264 include_invite: bool,
1265 extra_meta: serde_json::Value,
1266}
1267
1268#[derive(Deserialize)]
1269struct ValidateNotesRequest {
1270 oob_notes: OOBNotes,
1271}
1272
1273#[derive(Deserialize)]
1274struct TryCancelSpendNotesRequest {
1275 operation_id: OperationId,
1276}
1277
1278#[derive(Deserialize)]
1279struct SubscribeSpendNotesRequest {
1280 operation_id: OperationId,
1281}
1282
1283#[derive(Deserialize)]
1284struct AwaitSpendOobRefundRequest {
1285 operation_id: OperationId,
1286}
1287
1288#[derive(thiserror::Error, Debug, Clone)]
1289pub enum ReissueExternalNotesError {
1290 #[error("Federation ID does not match")]
1291 WrongFederationId,
1292 #[error("We already reissued these notes")]
1293 AlreadyReissued,
1294}
1295
1296impl MintClientModule {
1297 async fn create_sufficient_input(
1298 &self,
1299 dbtx: &mut DatabaseTransaction<'_>,
1300 min_amount: Amount,
1301 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1302 if min_amount == Amount::ZERO {
1303 return Ok(vec![]);
1304 }
1305
1306 let selected_notes = Self::select_notes(
1307 dbtx,
1308 &SelectNotesWithAtleastAmount,
1309 min_amount,
1310 self.cfg.fee_consensus.clone(),
1311 )
1312 .await?;
1313
1314 for (amount, note) in selected_notes.iter_items() {
1315 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as sufficient input to fund a tx");
1316 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1317 }
1318
1319 let sender = self.balance_update_sender.clone();
1320 dbtx.on_commit(move || sender.send_replace(()));
1321
1322 let inputs = self.create_input_from_notes(selected_notes)?;
1323
1324 assert!(!inputs.is_empty());
1325
1326 Ok(inputs)
1327 }
1328
1329 #[deprecated(
1331 since = "0.5.0",
1332 note = "Use `get_note_counts_by_denomination` instead"
1333 )]
1334 pub async fn get_notes_tier_counts(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1335 self.get_note_counts_by_denomination(dbtx).await
1336 }
1337
1338 pub async fn get_available_notes_by_tier_counts(
1342 &self,
1343 dbtx: &mut DatabaseTransaction<'_>,
1344 counts: TieredCounts,
1345 ) -> (TieredMulti<SpendableNoteUndecoded>, TieredCounts) {
1346 dbtx.find_by_prefix(&NoteKeyPrefix)
1347 .await
1348 .fold(
1349 (TieredMulti::<SpendableNoteUndecoded>::default(), counts),
1350 |(mut notes, mut counts), (key, note)| async move {
1351 let amount = key.amount;
1352 if 0 < counts.get(amount) {
1353 counts.dec(amount);
1354 notes.push(amount, note);
1355 }
1356
1357 (notes, counts)
1358 },
1359 )
1360 .await
1361 }
1362
1363 pub async fn create_output(
1368 &self,
1369 dbtx: &mut DatabaseTransaction<'_>,
1370 operation_id: OperationId,
1371 notes_per_denomination: u16,
1372 exact_amount: Amount,
1373 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1374 if exact_amount == Amount::ZERO {
1375 return ClientOutputBundle::new(vec![], vec![]);
1376 }
1377
1378 let denominations = represent_amount(
1381 exact_amount,
1382 &self.get_note_counts_by_denomination(dbtx).await,
1383 &self.cfg.tbs_pks,
1384 notes_per_denomination,
1385 &self.cfg.fee_consensus,
1386 );
1387
1388 self.create_output_for_denominations(dbtx, operation_id, denominations)
1389 .await
1390 }
1391
1392 async fn create_exact_output(
1404 &self,
1405 dbtx: &mut DatabaseTransaction<'_>,
1406 operation_id: OperationId,
1407 amount: Amount,
1408 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1409 if amount == Amount::ZERO {
1410 return ClientOutputBundle::new(vec![], vec![]);
1411 }
1412
1413 self.create_output_for_denominations(
1414 dbtx,
1415 operation_id,
1416 self.represent_exact_amount(amount),
1417 )
1418 .await
1419 }
1420
1421 fn represent_exact_amount(&self, amount: Amount) -> TieredCounts {
1427 represent_amount(
1428 amount,
1429 &TieredCounts::default(),
1430 &self.cfg.tbs_pks,
1431 0,
1432 &FeeConsensus::zero(),
1433 )
1434 }
1435
1436 async fn create_output_for_denominations(
1437 &self,
1438 dbtx: &mut DatabaseTransaction<'_>,
1439 operation_id: OperationId,
1440 denominations: TieredCounts,
1441 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1442 let mut outputs = Vec::new();
1443 let mut issuance_requests = Vec::new();
1444
1445 for (amount, num) in denominations.iter() {
1446 for _ in 0..num {
1447 let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await;
1448
1449 debug!(
1450 %amount,
1451 "Generated issuance request"
1452 );
1453
1454 outputs.push(ClientOutput {
1455 output: MintOutput::new_v0(amount, blind_nonce),
1456 amounts: Amounts::new_bitcoin(amount),
1457 });
1458
1459 issuance_requests.push((amount, issuance_request));
1460 }
1461 }
1462
1463 let state_generator = Arc::new(move |out_point_range: OutPointRange| {
1464 assert_eq!(out_point_range.count(), issuance_requests.len());
1465 vec![MintClientStateMachines::Output(MintOutputStateMachine {
1466 common: MintOutputCommon {
1467 operation_id,
1468 out_point_range,
1469 },
1470 state: MintOutputStates::CreatedMulti(MintOutputStatesCreatedMulti {
1471 issuance_requests: out_point_range
1472 .into_iter()
1473 .map(|out_point| out_point.out_idx)
1474 .zip(issuance_requests.clone())
1475 .collect(),
1476 }),
1477 })]
1478 });
1479
1480 ClientOutputBundle::new(
1481 outputs,
1482 vec![ClientOutputSM {
1483 state_machines: state_generator,
1484 }],
1485 )
1486 }
1487
1488 pub async fn get_note_counts_by_denomination(
1490 &self,
1491 dbtx: &mut DatabaseTransaction<'_>,
1492 ) -> TieredCounts {
1493 dbtx.find_by_prefix(&NoteKeyPrefix)
1494 .await
1495 .fold(
1496 TieredCounts::default(),
1497 |mut acc, (key, _note)| async move {
1498 acc.inc(key.amount, 1);
1499 acc
1500 },
1501 )
1502 .await
1503 }
1504
1505 #[deprecated(
1507 since = "0.5.0",
1508 note = "Use `get_note_counts_by_denomination` instead"
1509 )]
1510 pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1511 self.get_note_counts_by_denomination(dbtx).await
1512 }
1513
1514 pub async fn estimate_spend_all_fees(&self) -> Amount {
1520 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1521 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
1522
1523 note_counts
1524 .iter()
1525 .filter_map(|(amount, count)| {
1526 let note_fee = self.cfg.fee_consensus.fee(amount);
1527 if note_fee < amount {
1528 note_fee.checked_mul(count as u64)
1529 } else {
1530 None
1531 }
1532 })
1533 .fold(Amount::ZERO, |acc, fee| {
1534 acc.checked_add(fee).expect("fee sum overflow")
1535 })
1536 }
1537
1538 pub async fn await_output_finalized(
1542 &self,
1543 operation_id: OperationId,
1544 out_point: OutPoint,
1545 ) -> anyhow::Result<()> {
1546 let stream = self
1547 .notifier
1548 .subscribe(operation_id)
1549 .await
1550 .filter_map(|state| async {
1551 let MintClientStateMachines::Output(state) = state else {
1552 return None;
1553 };
1554
1555 if state.common.txid() != out_point.txid
1556 || !state
1557 .common
1558 .out_point_range
1559 .out_idx_iter()
1560 .contains(&out_point.out_idx)
1561 {
1562 return None;
1563 }
1564
1565 match state.state {
1566 MintOutputStates::Succeeded(_) => Some(Ok(())),
1567 MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
1568 MintOutputStates::Failed(failed) => Some(Err(anyhow!(
1569 "Failed to finalize transaction: {}",
1570 failed.error
1571 ))),
1572 MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1573 }
1574 });
1575 pin_mut!(stream);
1576
1577 stream.next_or_pending().await
1578 }
1579
1580 pub async fn consolidate_notes(
1587 &self,
1588 dbtx: &mut DatabaseTransaction<'_>,
1589 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1590 const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1593 const MIN_NOTES_PER_TIER: usize = 4;
1595 const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1598 #[allow(clippy::assertions_on_constants)]
1600 {
1601 assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1602 }
1603
1604 let counts = self.get_note_counts_by_denomination(dbtx).await;
1605
1606 let should_consolidate = counts
1607 .iter()
1608 .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1609
1610 if !should_consolidate {
1611 return Ok(vec![]);
1612 }
1613
1614 let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1615
1616 let excessive_counts: TieredCounts = counts
1617 .iter()
1618 .map(|(amount, count)| {
1619 let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1620
1621 max_count -= take;
1622 (amount, take)
1623 })
1624 .collect();
1625
1626 let (selected_notes, unavailable) = self
1627 .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1628 .await;
1629
1630 debug_assert!(
1631 unavailable.is_empty(),
1632 "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1633 );
1634
1635 if !selected_notes.is_empty() {
1636 debug!(target: LOG_CLIENT_MODULE_MINT, note_num=selected_notes.count_items(), denominations_msats=?selected_notes.iter_items().map(|(amount, _)| amount.msats).collect::<Vec<_>>(), "Will consolidate excessive notes");
1637 }
1638
1639 let mut selected_notes_decoded = vec![];
1640 for (amount, note) in selected_notes.iter_items() {
1641 let spendable_note_decoded = note.decode()?;
1642 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1643 Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1644 .await;
1645 selected_notes_decoded.push((amount, spendable_note_decoded));
1646 }
1647
1648 let sender = self.balance_update_sender.clone();
1649 dbtx.on_commit(move || sender.send_replace(()));
1650
1651 self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1652 }
1653
1654 #[allow(clippy::type_complexity)]
1656 pub fn create_input_from_notes(
1657 &self,
1658 notes: TieredMulti<SpendableNote>,
1659 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1660 let mut inputs_and_notes = Vec::new();
1661
1662 for (amount, spendable_note) in notes.into_iter_items() {
1663 let key = self
1664 .cfg
1665 .tbs_pks
1666 .get(amount)
1667 .ok_or(anyhow!("Invalid amount tier: {amount}"))?;
1668
1669 let note = spendable_note.note();
1670
1671 if !note.verify(*key) {
1672 bail!("Invalid note");
1673 }
1674
1675 inputs_and_notes.push((
1676 ClientInput {
1677 input: MintInput::new_v0(amount, note),
1678 keys: vec![spendable_note.spend_key],
1679 amounts: Amounts::new_bitcoin(amount),
1680 },
1681 spendable_note,
1682 ));
1683 }
1684
1685 Ok(inputs_and_notes)
1686 }
1687
1688 async fn spend_notes_oob(
1689 &self,
1690 dbtx: &mut DatabaseTransaction<'_>,
1691 notes_selector: &impl NotesSelector,
1692 amount: Amount,
1693 try_cancel_after: Option<Duration>,
1694 ) -> anyhow::Result<(
1695 OperationId,
1696 Vec<MintClientStateMachines>,
1697 TieredMulti<SpendableNote>,
1698 )> {
1699 ensure!(
1700 amount > Amount::ZERO,
1701 "zero-amount out-of-band spends are not supported"
1702 );
1703
1704 let selected_notes =
1705 Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1706
1707 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1708
1709 for (amount, note) in selected_notes.iter_items() {
1710 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1711 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1712 }
1713
1714 let sender = self.balance_update_sender.clone();
1715 dbtx.on_commit(move || sender.send_replace(()));
1716
1717 let try_cancel_after = try_cancel_after.unwrap_or(OOB_SPEND_NO_TIMEOUT);
1718 let state_machines = if try_cancel_after == OOB_SPEND_NO_TIMEOUT {
1719 vec![]
1720 } else {
1721 vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1722 operation_id,
1723 state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1724 spendable_notes: selected_notes.clone().into_iter_items().collect(),
1725 timeout: fedimint_core::time::now() + try_cancel_after,
1726 }),
1727 })]
1728 };
1729
1730 Ok((operation_id, state_machines, selected_notes))
1731 }
1732
1733 async fn is_no_timeout_oob_spend(&self, operation_id: OperationId) -> anyhow::Result<bool> {
1734 let operation = self.mint_operation(operation_id).await?;
1735 let MintOperationMetaVariant::SpendOOB { no_timeout, .. } =
1736 operation.meta::<MintOperationMeta>().variant
1737 else {
1738 bail!("Operation is not a out-of-band spend");
1739 };
1740
1741 Ok(no_timeout)
1742 }
1743
1744 pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1745 if self
1746 .is_no_timeout_oob_spend(operation_id)
1747 .await
1748 .unwrap_or(false)
1749 {
1750 return SpendOOBRefund {
1751 user_triggered: false,
1752 transaction_ids: vec![],
1753 };
1754 }
1755
1756 Box::pin(
1757 self.notifier
1758 .subscribe(operation_id)
1759 .await
1760 .filter_map(|state| async {
1761 let MintClientStateMachines::OOB(state) = state else {
1762 return None;
1763 };
1764
1765 match state.state {
1766 MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1767 user_triggered: false,
1768 transaction_ids: vec![refund.refund_txid],
1769 }),
1770 MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1771 user_triggered: true,
1772 transaction_ids: vec![refund.refund_txid],
1773 }),
1774 MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1775 user_triggered: true,
1776 transaction_ids: vec![refund.refund_txid],
1777 }),
1778 MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1779 }
1780 }),
1781 )
1782 .next_or_pending()
1783 .await
1784 }
1785
1786 async fn select_notes(
1788 dbtx: &mut DatabaseTransaction<'_>,
1789 notes_selector: &impl NotesSelector,
1790 requested_amount: Amount,
1791 fee_consensus: FeeConsensus,
1792 ) -> anyhow::Result<TieredMulti<SpendableNote>> {
1793 let note_stream = dbtx
1794 .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1795 .await
1796 .map(|(key, note)| (key.amount, note));
1797
1798 notes_selector
1799 .select_notes(note_stream, requested_amount, fee_consensus)
1800 .await?
1801 .into_iter_items()
1802 .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1803 .collect::<anyhow::Result<TieredMulti<_>>>()
1804 }
1805
1806 async fn get_all_spendable_notes(
1807 dbtx: &mut DatabaseTransaction<'_>,
1808 ) -> TieredMulti<SpendableNoteUndecoded> {
1809 (dbtx
1810 .find_by_prefix(&NoteKeyPrefix)
1811 .await
1812 .map(|(key, note)| (key.amount, note))
1813 .collect::<Vec<_>>()
1814 .await)
1815 .into_iter()
1816 .collect()
1817 }
1818
1819 async fn get_next_note_index(
1820 &self,
1821 dbtx: &mut DatabaseTransaction<'_>,
1822 amount: Amount,
1823 ) -> NoteIndex {
1824 NoteIndex(
1825 dbtx.get_value(&NextECashNoteIndexKey(amount))
1826 .await
1827 .unwrap_or(0),
1828 )
1829 }
1830
1831 pub fn new_note_secret_static(
1847 secret: &DerivableSecret,
1848 amount: Amount,
1849 note_idx: NoteIndex,
1850 ) -> DerivableSecret {
1851 assert_eq!(secret.level(), 2);
1852 debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1853 secret
1854 .child_key(MINT_E_CASH_TYPE_CHILD_ID) .child_key(ChildId(note_idx.as_u64()))
1856 .child_key(ChildId(amount.msats))
1857 }
1858
1859 async fn new_note_secret(
1863 &self,
1864 amount: Amount,
1865 dbtx: &mut DatabaseTransaction<'_>,
1866 ) -> DerivableSecret {
1867 let new_idx = self.get_next_note_index(dbtx, amount).await;
1868 dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1869 .await;
1870 Self::new_note_secret_static(&self.secret, amount, new_idx)
1871 }
1872
1873 pub async fn new_ecash_note(
1874 &self,
1875 amount: Amount,
1876 dbtx: &mut DatabaseTransaction<'_>,
1877 ) -> (NoteIssuanceRequest, BlindNonce) {
1878 let secret = self.new_note_secret(amount, dbtx).await;
1879 NoteIssuanceRequest::new(&self.secp, &secret)
1880 }
1881
1882 pub async fn reissue_fee_quote(&self, oob_notes: &OOBNotes) -> anyhow::Result<FeeQuote> {
1892 let input_amount = oob_notes.total_amount();
1897 let input_fee: Amount = oob_notes
1898 .notes()
1899 .iter_items()
1900 .map(|(amount, _)| self.cfg.fee_consensus.fee(amount))
1901 .sum();
1902
1903 self.client_ctx
1904 .fee_quote(
1905 OperationId::new_random(),
1906 FeeQuoteRequest {
1907 input_amount: Amounts::new_bitcoin(input_amount),
1908 output_amount: Amounts::ZERO,
1909 input_fee: Amounts::new_bitcoin(input_fee),
1910 output_fee: Amounts::ZERO,
1911 },
1912 )
1913 .await
1914 }
1915
1916 pub async fn send_fee_quote(&self, amount: Amount) -> anyhow::Result<FeeQuote> {
1930 let amount = self.cfg.fee_consensus.round_up(amount);
1931
1932 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1933
1934 if Self::select_notes(
1938 &mut dbtx,
1939 &SelectNotesWithExactAmount,
1940 amount,
1941 FeeConsensus::zero(),
1942 )
1943 .await
1944 .is_ok()
1945 {
1946 return Ok(FeeQuote::ZERO);
1947 }
1948
1949 drop(dbtx);
1950
1951 let denominations = self.represent_exact_amount(amount);
1957
1958 let output_amount = denominations.total_amount();
1959 let output_fee: Amount = denominations
1960 .iter()
1961 .map(|(denomination, count)| self.cfg.fee_consensus.fee(denomination) * count as u64)
1962 .sum();
1963
1964 self.client_ctx
1965 .fee_quote(
1966 OperationId::new_random(),
1967 FeeQuoteRequest {
1968 input_amount: Amounts::ZERO,
1969 output_amount: Amounts::new_bitcoin(output_amount),
1970 input_fee: Amounts::ZERO,
1971 output_fee: Amounts::new_bitcoin(output_fee),
1972 },
1973 )
1974 .await
1975 }
1976
1977 pub async fn reissue_external_notes<M: Serialize + Send>(
1982 &self,
1983 oob_notes: OOBNotes,
1984 extra_meta: M,
1985 ) -> anyhow::Result<OperationId> {
1986 let notes = oob_notes.notes().clone();
1987 let federation_id_prefix = oob_notes.federation_id_prefix();
1988
1989 debug!(
1990 target: LOG_CLIENT_MODULE_MINT,
1991 notes = ?notes
1992 .iter_items()
1993 .map(|(amount, note)| (amount, note.nonce()))
1994 .collect::<Vec<_>>(),
1995 "Reissuing external notes"
1996 );
1997
1998 ensure!(
1999 notes.total_amount() > Amount::ZERO,
2000 "Reissuing zero-amount e-cash isn't supported"
2001 );
2002
2003 if federation_id_prefix != self.federation_id.to_prefix() {
2004 bail!(ReissueExternalNotesError::WrongFederationId);
2005 }
2006
2007 let operation_id = OperationId(
2008 notes
2009 .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
2010 .to_byte_array(),
2011 );
2012
2013 let amount = notes.total_amount();
2014 let mint_inputs = self.create_input_from_notes(notes)?;
2015
2016 let tx = TransactionBuilder::new().with_inputs(
2017 self.client_ctx
2018 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
2019 );
2020
2021 let extra_meta = serde_json::to_value(extra_meta)
2022 .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
2023 let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
2024 variant: MintOperationMetaVariant::Reissuance {
2025 legacy_out_point: None,
2026 txid: Some(change_range.txid()),
2027 out_point_indices: change_range
2028 .into_iter()
2029 .map(|out_point| out_point.out_idx)
2030 .collect(),
2031 },
2032 amount,
2033 extra_meta: extra_meta.clone(),
2034 };
2035
2036 self.client_ctx
2037 .finalize_and_submit_transaction(
2038 operation_id,
2039 MintCommonInit::KIND.as_str(),
2040 operation_meta_gen,
2041 tx,
2042 )
2043 .await
2044 .context(ReissueExternalNotesError::AlreadyReissued)?;
2045
2046 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2047
2048 self.client_ctx
2049 .log_event(&mut dbtx, OOBNotesReissued { amount })
2050 .await;
2051
2052 self.client_ctx
2053 .log_event(
2054 &mut dbtx,
2055 ReceivePaymentEvent {
2056 operation_id,
2057 amount,
2058 },
2059 )
2060 .await;
2061
2062 dbtx.commit_tx().await;
2063
2064 Ok(operation_id)
2065 }
2066
2067 pub async fn subscribe_reissue_external_notes(
2070 &self,
2071 operation_id: OperationId,
2072 ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
2073 let operation = self.mint_operation(operation_id).await?;
2074 let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
2075 MintOperationMetaVariant::Reissuance {
2076 legacy_out_point,
2077 txid,
2078 out_point_indices,
2079 } => {
2080 let txid = txid
2083 .or(legacy_out_point.map(|out_point| out_point.txid))
2084 .context("Empty reissuance not permitted, this should never happen")?;
2085
2086 let out_points = out_point_indices
2087 .into_iter()
2088 .map(|out_idx| OutPoint { txid, out_idx })
2089 .chain(legacy_out_point)
2090 .collect::<Vec<_>>();
2091
2092 (txid, out_points)
2093 }
2094 MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"),
2095 };
2096
2097 let client_ctx = self.client_ctx.clone();
2098
2099 Ok(self.client_ctx.outcome_or_updates(
2100 &operation,
2101 operation_id,
2102 |state| match state {
2103 ReissueExternalNotesState::Created | ReissueExternalNotesState::Issuing => false,
2104 ReissueExternalNotesState::Done | ReissueExternalNotesState::Failed(_) => true,
2105 },
2106 move || {
2107 stream! {
2108 yield ReissueExternalNotesState::Created;
2109
2110 match client_ctx
2111 .transaction_updates(operation_id)
2112 .await
2113 .await_tx_accepted(txid)
2114 .await
2115 {
2116 Ok(()) => {
2117 yield ReissueExternalNotesState::Issuing;
2118 }
2119 Err(e) => {
2120 yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
2121 return;
2122 }
2123 }
2124
2125 for out_point in out_points {
2126 if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
2127 yield ReissueExternalNotesState::Failed(e.to_string());
2128 return;
2129 }
2130 }
2131 yield ReissueExternalNotesState::Done;
2132 }}
2133 ))
2134 }
2135
2136 #[deprecated(
2149 since = "0.5.0",
2150 note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
2151 )]
2152 pub async fn spend_notes<M: Serialize + Send>(
2153 &self,
2154 min_amount: Amount,
2155 try_cancel_after: Option<Duration>,
2156 include_invite: bool,
2157 extra_meta: M,
2158 ) -> anyhow::Result<(OperationId, OOBNotes)> {
2159 self.spend_notes_with_selector(
2160 &SelectNotesWithAtleastAmount,
2161 min_amount,
2162 try_cancel_after,
2163 include_invite,
2164 extra_meta,
2165 )
2166 .await
2167 }
2168
2169 pub async fn spend_notes_with_selector<M: Serialize + Send>(
2186 &self,
2187 notes_selector: &impl NotesSelector,
2188 requested_amount: Amount,
2189 try_cancel_after: Option<Duration>,
2190 include_invite: bool,
2191 extra_meta: M,
2192 ) -> anyhow::Result<(OperationId, OOBNotes)> {
2193 let federation_id_prefix = self.federation_id.to_prefix();
2194 let extra_meta = serde_json::to_value(extra_meta)
2195 .expect("MintClientModule::spend_notes extra_meta is serializable");
2196
2197 self.client_ctx
2198 .module_db()
2199 .autocommit(
2200 |dbtx, _| {
2201 let extra_meta = extra_meta.clone();
2202 Box::pin(async {
2203 let no_timeout = try_cancel_after.is_none();
2204 let (operation_id, states, notes) = self
2205 .spend_notes_oob(
2206 dbtx,
2207 notes_selector,
2208 requested_amount,
2209 try_cancel_after,
2210 )
2211 .await?;
2212
2213 let oob_notes = if include_invite {
2214 OOBNotes::new_with_invite(
2215 notes,
2216 &self.client_ctx.get_invite_code().await,
2217 )
2218 } else {
2219 OOBNotes::new(federation_id_prefix, notes)
2220 };
2221
2222 self.client_ctx
2223 .add_state_machines_dbtx(
2224 dbtx,
2225 self.client_ctx.map_dyn(states).collect(),
2226 )
2227 .await?;
2228 self.client_ctx
2229 .add_operation_log_entry_dbtx(
2230 dbtx,
2231 operation_id,
2232 MintCommonInit::KIND.as_str(),
2233 MintOperationMeta {
2234 variant: MintOperationMetaVariant::SpendOOB {
2235 requested_amount,
2236 oob_notes: oob_notes.clone(),
2237 no_timeout,
2238 },
2239 amount: oob_notes.total_amount(),
2240 extra_meta,
2241 },
2242 )
2243 .await;
2244 self.client_ctx
2245 .log_event(
2246 dbtx,
2247 OOBNotesSpent {
2248 requested_amount,
2249 spent_amount: oob_notes.total_amount(),
2250 timeout: try_cancel_after,
2251 include_invite,
2252 },
2253 )
2254 .await;
2255
2256 self.client_ctx
2257 .log_event(
2258 dbtx,
2259 SendPaymentEvent {
2260 operation_id,
2261 amount: oob_notes.total_amount(),
2262 oob_notes: encode_prefixed(FEDIMINT_PREFIX, &oob_notes),
2263 },
2264 )
2265 .await;
2266
2267 Ok((operation_id, oob_notes))
2268 })
2269 },
2270 Some(100),
2271 )
2272 .await
2273 .map_err(|e| match e {
2274 AutocommitError::ClosureError { error, .. } => error,
2275 AutocommitError::CommitFailed { last_error, .. } => {
2276 anyhow!("Commit to DB failed: {last_error}")
2277 }
2278 })
2279 }
2280
2281 pub async fn send_oob_notes<M: Serialize + Send>(
2308 &self,
2309 amount: Amount,
2310 extra_meta: M,
2311 ) -> anyhow::Result<OOBNotes> {
2312 let amount = self.cfg.fee_consensus.round_up(amount);
2313
2314 let extra_meta = serde_json::to_value(extra_meta)
2315 .expect("MintClientModule::send_oob_notes extra_meta is serializable");
2316
2317 let oob_notes: Option<OOBNotes> = self
2319 .client_ctx
2320 .module_db()
2321 .autocommit(
2322 |dbtx, _| {
2323 let extra_meta = extra_meta.clone();
2324 Box::pin(async {
2325 self.try_spend_exact_notes_dbtx(
2326 dbtx,
2327 amount,
2328 self.federation_id,
2329 extra_meta,
2330 )
2331 .await
2332 .map(Ok::<OOBNotes, anyhow::Error>)
2333 .transpose()
2334 })
2335 },
2336 Some(100),
2337 )
2338 .await
2339 .expect("Failed to commit dbtx after 100 retries");
2340
2341 if let Some(oob_notes) = oob_notes {
2342 return Ok(oob_notes);
2343 }
2344
2345 self.client_ctx
2347 .global_api()
2348 .session_count()
2349 .await
2350 .context("Cannot reach federation to reissue notes")?;
2351
2352 let operation_id = OperationId::new_random();
2353
2354 let output_bundle = self
2361 .client_ctx
2362 .module_db()
2363 .autocommit(
2364 |dbtx, _| {
2365 Box::pin(async {
2366 Ok::<_, anyhow::Error>(
2367 self.create_exact_output(dbtx, operation_id, amount).await,
2368 )
2369 })
2370 },
2371 Some(100),
2372 )
2373 .await
2374 .expect("Failed to commit output creation after 100 retries");
2375
2376 let explicit_output_count = output_bundle.outputs().len() as u64;
2382
2383 let combined_bundle = ClientOutputBundle::new(
2385 output_bundle.outputs().to_vec(),
2386 output_bundle.sms().to_vec(),
2387 );
2388
2389 let outputs = self.client_ctx.make_client_outputs(combined_bundle);
2390
2391 let em_clone = extra_meta.clone();
2392
2393 let out_point_range = self
2395 .client_ctx
2396 .finalize_and_submit_transaction(
2397 operation_id,
2398 MintCommonInit::KIND.as_str(),
2399 move |change_range: OutPointRange| MintOperationMeta {
2400 variant: MintOperationMetaVariant::Reissuance {
2401 legacy_out_point: None,
2402 txid: Some(change_range.txid()),
2403 out_point_indices: change_range
2404 .into_iter()
2405 .map(|out_point| out_point.out_idx)
2406 .collect(),
2407 },
2408 amount,
2409 extra_meta: em_clone.clone(),
2410 },
2411 TransactionBuilder::new().with_outputs(outputs),
2412 )
2413 .await
2414 .context("Failed to submit reissuance transaction")?;
2415
2416 let txid = out_point_range.txid();
2423 let total_output_count = explicit_output_count + out_point_range.count() as u64;
2424 let all_outputs = OutPointRange::new(txid, IdxRange::from(0..total_output_count));
2425 self.client_ctx
2426 .await_primary_module_outputs(operation_id, all_outputs.into_iter().collect())
2427 .await
2428 .context("Failed to await output finalization")?;
2429
2430 Box::pin(self.send_oob_notes(amount, extra_meta)).await
2432 }
2433
2434 async fn try_spend_exact_notes_dbtx(
2437 &self,
2438 dbtx: &mut DatabaseTransaction<'_>,
2439 amount: Amount,
2440 federation_id: FederationId,
2441 extra_meta: serde_json::Value,
2442 ) -> Option<OOBNotes> {
2443 let selected_notes = Self::select_notes(
2444 dbtx,
2445 &SelectNotesWithExactAmount,
2446 amount,
2447 FeeConsensus::zero(),
2448 )
2449 .await
2450 .ok()?;
2451
2452 for (note_amount, note) in selected_notes.iter_items() {
2454 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, note_amount, note)
2455 .await;
2456 }
2457
2458 let sender = self.balance_update_sender.clone();
2459 dbtx.on_commit(move || sender.send_replace(()));
2460
2461 let operation_id = spendable_notes_to_operation_id(&selected_notes);
2462
2463 let oob_notes = OOBNotes::new(federation_id.to_prefix(), selected_notes);
2464
2465 self.client_ctx
2467 .add_operation_log_entry_dbtx(
2468 dbtx,
2469 operation_id,
2470 MintCommonInit::KIND.as_str(),
2471 MintOperationMeta {
2472 variant: MintOperationMetaVariant::SpendOOB {
2473 requested_amount: amount,
2474 oob_notes: oob_notes.clone(),
2475 no_timeout: true,
2476 },
2477 amount: oob_notes.total_amount(),
2478 extra_meta,
2479 },
2480 )
2481 .await;
2482
2483 self.client_ctx
2484 .log_event(
2485 dbtx,
2486 SendPaymentEvent {
2487 operation_id,
2488 amount: oob_notes.total_amount(),
2489 oob_notes: encode_prefixed(FEDIMINT_PREFIX, &oob_notes),
2490 },
2491 )
2492 .await;
2493
2494 Some(oob_notes)
2495 }
2496
2497 pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> {
2503 let federation_id_prefix = oob_notes.federation_id_prefix();
2504 let notes = oob_notes.notes().clone();
2505
2506 if federation_id_prefix != self.federation_id.to_prefix() {
2507 bail!("Federation ID does not match");
2508 }
2509
2510 let tbs_pks = &self.cfg.tbs_pks;
2511
2512 for (idx, (amt, snote)) in notes.iter_items().enumerate() {
2513 let key = tbs_pks
2514 .get(amt)
2515 .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
2516
2517 let note = snote.note();
2518 if !note.verify(*key) {
2519 bail!("Note {idx} has an invalid federation signature");
2520 }
2521
2522 let expected_nonce = Nonce(snote.spend_key.public_key());
2523 if note.nonce != expected_nonce {
2524 bail!("Note {idx} cannot be spent using the supplied spend key");
2525 }
2526 }
2527
2528 Ok(notes.total_amount())
2529 }
2530
2531 pub async fn check_note_spent(&self, oob_notes: &OOBNotes) -> anyhow::Result<bool> {
2537 use crate::api::MintFederationApi;
2538
2539 let api_client = self.client_ctx.module_api();
2540 let any_spent = try_join_all(oob_notes.notes().iter().flat_map(|(_, notes)| {
2541 notes
2542 .iter()
2543 .map(|note| api_client.check_note_spent(note.nonce()))
2544 }))
2545 .await?
2546 .into_iter()
2547 .any(|spent| spent);
2548
2549 Ok(any_spent)
2550 }
2551
2552 pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
2557 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2558 dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
2559 .await;
2560 if let Err(e) = dbtx.commit_tx_result().await {
2561 warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
2562 }
2563 }
2564
2565 pub async fn subscribe_spend_notes(
2568 &self,
2569 operation_id: OperationId,
2570 ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
2571 let operation = self.mint_operation(operation_id).await?;
2572 let MintOperationMetaVariant::SpendOOB { no_timeout, .. } =
2573 operation.meta::<MintOperationMeta>().variant
2574 else {
2575 bail!("Operation is not a out-of-band spend");
2576 };
2577
2578 let client_ctx = self.client_ctx.clone();
2579
2580 Ok(self.client_ctx.outcome_or_updates(
2581 &operation,
2582 operation_id,
2583 |state| match state {
2584 SpendOOBState::Created | SpendOOBState::UserCanceledProcessing => false,
2585 SpendOOBState::UserCanceledSuccess
2586 | SpendOOBState::UserCanceledFailure
2587 | SpendOOBState::Success
2588 | SpendOOBState::Refunded => true,
2589 },
2590 move || {
2591 stream! {
2592 yield SpendOOBState::Created;
2593
2594 if no_timeout {
2595 yield SpendOOBState::Success;
2596 return;
2597 }
2598
2599 let self_ref = client_ctx.self_ref();
2600
2601 let refund = self_ref
2602 .await_spend_oob_refund(operation_id)
2603 .await;
2604
2605 if refund.user_triggered {
2606 yield SpendOOBState::UserCanceledProcessing;
2607 }
2608
2609 let mut success = true;
2610
2611 for txid in refund.transaction_ids {
2612 debug!(
2613 target: LOG_CLIENT_MODULE_MINT,
2614 %txid,
2615 operation_id=%operation_id.fmt_short(),
2616 "Waiting for oob refund txid"
2617 );
2618 if client_ctx
2619 .transaction_updates(operation_id)
2620 .await
2621 .await_tx_accepted(txid)
2622 .await.is_err() {
2623 success = false;
2624 }
2625 }
2626
2627 debug!(
2628 target: LOG_CLIENT_MODULE_MINT,
2629 operation_id=%operation_id.fmt_short(),
2630 %success,
2631 "Done waiting for all refund oob txids"
2632 );
2633
2634 match (refund.user_triggered, success) {
2635 (true, true) => {
2636 yield SpendOOBState::UserCanceledSuccess;
2637 },
2638 (true, false) => {
2639 yield SpendOOBState::UserCanceledFailure;
2640 },
2641 (false, true) => {
2642 yield SpendOOBState::Refunded;
2643 },
2644 (false, false) => {
2645 yield SpendOOBState::Success;
2646 }
2647 }
2648 }
2649 },
2650 ))
2651 }
2652
2653 async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
2654 let operation = self.client_ctx.get_operation(operation_id).await?;
2655
2656 if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
2657 bail!("Operation is not a mint operation");
2658 }
2659
2660 Ok(operation)
2661 }
2662
2663 async fn delete_spendable_note(
2664 client_ctx: &ClientContext<MintClientModule>,
2665 dbtx: &mut DatabaseTransaction<'_>,
2666 amount: Amount,
2667 note: &SpendableNote,
2668 ) {
2669 client_ctx
2670 .log_event(
2671 dbtx,
2672 NoteSpent {
2673 nonce: note.nonce(),
2674 },
2675 )
2676 .await;
2677 dbtx.remove_entry(&NoteKey {
2678 amount,
2679 nonce: note.nonce(),
2680 })
2681 .await
2682 .expect("Must deleted existing spendable note");
2683 }
2684
2685 pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> {
2686 let db = self.client_ctx.module_db().clone();
2687
2688 Ok(db
2689 .autocommit(
2690 |dbtx, _| {
2691 Box::pin(async {
2692 Ok::<DerivableSecret, anyhow::Error>(
2693 self.new_note_secret(amount, dbtx).await,
2694 )
2695 })
2696 },
2697 None,
2698 )
2699 .await?)
2700 }
2701
2702 pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
2705 self.client_ctx
2706 .module_db()
2707 .begin_transaction_nc()
2708 .await
2709 .get_value(&ReusedNoteIndices)
2710 .await
2711 .unwrap_or_default()
2712 .into_iter()
2713 .map(|(amount, note_idx)| {
2714 let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
2715 let (request, blind_nonce) =
2716 NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
2717 (amount, request, blind_nonce)
2718 })
2719 .collect()
2720 }
2721}
2722
2723pub fn spendable_notes_to_operation_id(
2724 spendable_selected_notes: &TieredMulti<SpendableNote>,
2725) -> OperationId {
2726 OperationId(
2727 spendable_selected_notes
2728 .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
2729 .to_byte_array(),
2730 )
2731}
2732
2733#[derive(Debug, Serialize, Deserialize, Clone)]
2734#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
2735pub struct SpendOOBRefund {
2736 pub user_triggered: bool,
2737 pub transaction_ids: Vec<TransactionId>,
2740}
2741
2742#[apply(async_trait_maybe_send!)]
2745pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
2746 async fn select_notes(
2749 &self,
2750 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2752 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2753 requested_amount: Amount,
2754 fee_consensus: FeeConsensus,
2755 ) -> anyhow::Result<TieredMulti<Note>>;
2756}
2757
2758pub struct SelectNotesWithAtleastAmount;
2764
2765#[apply(async_trait_maybe_send!)]
2766impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
2767 async fn select_notes(
2768 &self,
2769 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2770 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2771 requested_amount: Amount,
2772 fee_consensus: FeeConsensus,
2773 ) -> anyhow::Result<TieredMulti<Note>> {
2774 Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
2775 }
2776}
2777
2778pub struct SelectNotesWithExactAmount;
2782
2783#[apply(async_trait_maybe_send!)]
2784impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
2785 async fn select_notes(
2786 &self,
2787 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2788 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2789 requested_amount: Amount,
2790 fee_consensus: FeeConsensus,
2791 ) -> anyhow::Result<TieredMulti<Note>> {
2792 let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
2793
2794 if notes.total_amount() != requested_amount {
2795 bail!(
2796 "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}",
2797 requested_amount,
2798 notes.total_amount()
2799 );
2800 }
2801
2802 Ok(notes)
2803 }
2804}
2805
2806async fn select_notes_from_stream<Note>(
2812 stream: impl futures::Stream<Item = (Amount, Note)>,
2813 requested_amount: Amount,
2814 fee_consensus: FeeConsensus,
2815) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
2816 if requested_amount == Amount::ZERO {
2817 return Ok(TieredMulti::default());
2818 }
2819 let mut stream = Box::pin(stream);
2820 let mut selected = vec![];
2821 let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
2826 let mut pending_amount = requested_amount;
2827 let mut previous_amount: Option<Amount> = None; loop {
2829 if let Some((note_amount, note)) = stream.next().await {
2830 assert!(
2831 previous_amount.is_none_or(|previous| previous >= note_amount),
2832 "notes are not sorted in descending order"
2833 );
2834 previous_amount = Some(note_amount);
2835
2836 if note_amount <= fee_consensus.fee(note_amount) {
2837 continue;
2838 }
2839
2840 match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2841 Ordering::Less => {
2842 pending_amount += fee_consensus.fee(note_amount);
2844 pending_amount -= note_amount;
2845 selected.push((note_amount, note));
2846 }
2847 Ordering::Greater => {
2848 last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2852 }
2853 Ordering::Equal => {
2854 selected.push((note_amount, note));
2856
2857 let notes: TieredMulti<Note> = selected.into_iter().collect();
2858
2859 assert!(
2860 notes.total_amount().msats
2861 >= requested_amount.msats
2862 + notes
2863 .iter()
2864 .map(|note| fee_consensus.fee(note.0))
2865 .sum::<Amount>()
2866 .msats
2867 );
2868
2869 return Ok(notes);
2870 }
2871 }
2872 } else {
2873 assert!(pending_amount > Amount::ZERO);
2874 if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2875 selected.truncate(checkpoint);
2878 selected.push((big_note_amount, big_note));
2880
2881 let notes: TieredMulti<Note> = selected.into_iter().collect();
2882
2883 assert!(
2884 notes.total_amount().msats
2885 >= requested_amount.msats
2886 + notes
2887 .iter()
2888 .map(|note| fee_consensus.fee(note.0))
2889 .sum::<Amount>()
2890 .msats
2891 );
2892
2893 return Ok(notes);
2895 }
2896
2897 let total_amount = requested_amount.saturating_sub(pending_amount);
2898 return Err(InsufficientBalanceError {
2900 requested_amount,
2901 total_amount,
2902 });
2903 }
2904 }
2905}
2906
2907#[derive(Debug, Clone, Error)]
2908pub struct InsufficientBalanceError {
2909 pub requested_amount: Amount,
2910 pub total_amount: Amount,
2911}
2912
2913impl std::fmt::Display for InsufficientBalanceError {
2914 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2915 write!(
2916 f,
2917 "Insufficient balance: requested {} but only {} available",
2918 self.requested_amount, self.total_amount
2919 )
2920 }
2921}
2922
2923#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2925enum MintRestoreStates {
2926 #[encodable_default]
2927 Default { variant: u64, bytes: Vec<u8> },
2928}
2929
2930#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2932pub struct MintRestoreStateMachine {
2933 operation_id: OperationId,
2934 state: MintRestoreStates,
2935}
2936
2937#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2938pub enum MintClientStateMachines {
2939 Output(MintOutputStateMachine),
2940 Input(MintInputStateMachine),
2941 OOB(MintOOBStateMachine),
2942 Restore(MintRestoreStateMachine),
2944}
2945
2946impl IntoDynInstance for MintClientStateMachines {
2947 type DynType = DynState;
2948
2949 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2950 DynState::from_typed(instance_id, self)
2951 }
2952}
2953
2954impl State for MintClientStateMachines {
2955 type ModuleContext = MintClientContext;
2956
2957 fn transitions(
2958 &self,
2959 context: &Self::ModuleContext,
2960 global_context: &DynGlobalClientContext,
2961 ) -> Vec<StateTransition<Self>> {
2962 match self {
2963 MintClientStateMachines::Output(issuance_state) => {
2964 sm_enum_variant_translation!(
2965 issuance_state.transitions(context, global_context),
2966 MintClientStateMachines::Output
2967 )
2968 }
2969 MintClientStateMachines::Input(redemption_state) => {
2970 sm_enum_variant_translation!(
2971 redemption_state.transitions(context, global_context),
2972 MintClientStateMachines::Input
2973 )
2974 }
2975 MintClientStateMachines::OOB(oob_state) => {
2976 sm_enum_variant_translation!(
2977 oob_state.transitions(context, global_context),
2978 MintClientStateMachines::OOB
2979 )
2980 }
2981 MintClientStateMachines::Restore(_) => {
2982 sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
2983 }
2984 }
2985 }
2986
2987 fn operation_id(&self) -> OperationId {
2988 match self {
2989 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
2990 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
2991 MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
2992 MintClientStateMachines::Restore(r) => r.operation_id,
2993 }
2994 }
2995
2996 fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
2997 match self {
2998 MintClientStateMachines::Output(s) => s.fmt_visualization(f, indent),
2999 MintClientStateMachines::Input(s) => s.fmt_visualization(f, indent),
3000 MintClientStateMachines::OOB(s) => s.fmt_visualization(f, indent),
3001 MintClientStateMachines::Restore(_) => write!(f, "{indent}{self:?}"),
3002 }
3003 }
3004}
3005
3006#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
3009pub struct SpendableNote {
3010 pub signature: tbs::Signature,
3011 pub spend_key: Keypair,
3012}
3013
3014impl fmt::Debug for SpendableNote {
3015 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3016 f.debug_struct("SpendableNote")
3017 .field("nonce", &self.nonce())
3018 .field("signature", &self.signature)
3019 .field("spend_key", &self.spend_key)
3020 .finish()
3021 }
3022}
3023impl fmt::Display for SpendableNote {
3024 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3025 write!(f, "{}", self.nonce().fmt_short())
3026 }
3027}
3028
3029impl SpendableNote {
3030 pub fn nonce(&self) -> Nonce {
3031 Nonce(self.spend_key.public_key())
3032 }
3033
3034 fn note(&self) -> Note {
3035 Note {
3036 nonce: self.nonce(),
3037 signature: self.signature,
3038 }
3039 }
3040
3041 pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
3042 SpendableNoteUndecoded {
3043 signature: self
3044 .signature
3045 .consensus_encode_to_vec()
3046 .try_into()
3047 .expect("Encoded size always correct"),
3048 spend_key: self.spend_key,
3049 }
3050 }
3051}
3052
3053#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
3065pub struct SpendableNoteUndecoded {
3066 #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
3069 pub signature: [u8; 48],
3070 pub spend_key: Keypair,
3071}
3072
3073impl fmt::Display for SpendableNoteUndecoded {
3074 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3075 write!(f, "{}", self.nonce().fmt_short())
3076 }
3077}
3078
3079impl fmt::Debug for SpendableNoteUndecoded {
3080 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3081 f.debug_struct("SpendableNote")
3082 .field("nonce", &self.nonce())
3083 .field("signature", &"[raw]")
3084 .field("spend_key", &self.spend_key)
3085 .finish()
3086 }
3087}
3088
3089impl SpendableNoteUndecoded {
3090 fn nonce(&self) -> Nonce {
3091 Nonce(self.spend_key.public_key())
3092 }
3093
3094 pub fn decode(self) -> anyhow::Result<SpendableNote> {
3095 Ok(SpendableNote {
3096 signature: Decodable::consensus_decode_partial_from_finite_reader(
3097 &mut self.signature.as_slice(),
3098 &ModuleRegistry::default(),
3099 )?,
3100 spend_key: self.spend_key,
3101 })
3102 }
3103}
3104
3105#[derive(
3111 Copy,
3112 Clone,
3113 Debug,
3114 Serialize,
3115 Deserialize,
3116 PartialEq,
3117 Eq,
3118 Encodable,
3119 Decodable,
3120 Default,
3121 PartialOrd,
3122 Ord,
3123)]
3124pub struct NoteIndex(u64);
3125
3126impl NoteIndex {
3127 pub fn next(self) -> Self {
3128 Self(self.0 + 1)
3129 }
3130
3131 fn prev(self) -> Option<Self> {
3132 self.0.checked_sub(0).map(Self)
3133 }
3134
3135 pub fn as_u64(self) -> u64 {
3136 self.0
3137 }
3138
3139 #[allow(unused)]
3143 pub fn from_u64(v: u64) -> Self {
3144 Self(v)
3145 }
3146
3147 pub fn advance(&mut self) {
3148 *self = self.next();
3149 }
3150}
3151
3152impl std::fmt::Display for NoteIndex {
3153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3154 self.0.fmt(f)
3155 }
3156}
3157
3158struct OOBSpendTag;
3159
3160impl sha256t::Tag for OOBSpendTag {
3161 fn engine() -> sha256::HashEngine {
3162 let mut engine = sha256::HashEngine::default();
3163 engine.input(b"oob-spend");
3164 engine
3165 }
3166}
3167
3168struct OOBReissueTag;
3169
3170impl sha256t::Tag for OOBReissueTag {
3171 fn engine() -> sha256::HashEngine {
3172 let mut engine = sha256::HashEngine::default();
3173 engine.input(b"oob-reissue");
3174 engine
3175 }
3176}
3177
3178pub fn represent_amount<K>(
3184 amount: Amount,
3185 current_denominations: &TieredCounts,
3186 tiers: &Tiered<K>,
3187 denomination_sets: u16,
3188 fee_consensus: &FeeConsensus,
3189) -> TieredCounts {
3190 let mut remaining_amount = amount;
3191 let mut denominations = TieredCounts::default();
3192
3193 for tier in tiers.tiers() {
3195 let notes = current_denominations.get(*tier);
3196 let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
3197 let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
3198
3199 let add_notes = min(possible_notes, missing_notes);
3200 denominations.inc(*tier, add_notes as usize);
3201 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
3202 }
3203
3204 for tier in tiers.tiers().rev() {
3206 let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
3207 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
3208 denominations.inc(*tier, res as usize);
3209 }
3210
3211 let represented: u64 = denominations
3212 .iter()
3213 .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
3214 .sum();
3215
3216 assert!(represented <= amount.msats);
3217 assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
3218
3219 denominations
3220}
3221
3222pub(crate) fn create_bundle_for_inputs(
3223 inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
3224 operation_id: OperationId,
3225) -> ClientInputBundle<MintInput, MintClientStateMachines> {
3226 let mut inputs = Vec::new();
3227 let mut input_states = Vec::new();
3228
3229 for (input, spendable_note) in inputs_and_notes {
3230 input_states.push((input.amounts.clone(), spendable_note));
3231 inputs.push(input);
3232 }
3233
3234 let input_sm = Arc::new(move |out_point_range: OutPointRange| {
3235 debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
3236
3237 vec![MintClientStateMachines::Input(MintInputStateMachine {
3238 common: MintInputCommon {
3239 operation_id,
3240 out_point_range,
3241 },
3242 state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
3243 notes: input_states
3244 .iter()
3245 .map(|(amounts, note)| (amounts.expect_only_bitcoin(), *note))
3246 .collect(),
3247 }),
3248 })]
3249 });
3250
3251 ClientInputBundle::new(
3252 inputs,
3253 vec![ClientInputSM {
3254 state_machines: input_sm,
3255 }],
3256 )
3257}
3258
3259#[cfg(test)]
3260mod tests {
3261 use std::fmt::Display;
3262 use std::str::FromStr;
3263
3264 use bitcoin_hashes::Hash;
3265 use fedimint_core::base32::FEDIMINT_PREFIX;
3266 use fedimint_core::config::FederationId;
3267 use fedimint_core::encoding::Decodable;
3268 use fedimint_core::invite_code::InviteCode;
3269 use fedimint_core::module::registry::ModuleRegistry;
3270 use fedimint_core::{
3271 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
3272 };
3273 use fedimint_mint_common::config::FeeConsensus;
3274 use itertools::Itertools;
3275 use serde_json::json;
3276
3277 use crate::{
3278 MintOperationMetaVariant, OOBNotes, OOBNotesPart, SpendableNote, SpendableNoteUndecoded,
3279 represent_amount, select_notes_from_stream,
3280 };
3281
3282 #[test]
3283 fn represent_amount_targets_denomination_sets() {
3284 fn tiers(tiers: Vec<u64>) -> Tiered<()> {
3285 tiers
3286 .into_iter()
3287 .map(|tier| (Amount::from_sats(tier), ()))
3288 .collect()
3289 }
3290
3291 fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
3292 TieredCounts::from_iter(denominations)
3293 }
3294
3295 let starting = notes(vec![
3296 (Amount::from_sats(1), 1),
3297 (Amount::from_sats(2), 3),
3298 (Amount::from_sats(3), 2),
3299 ])
3300 .summary();
3301 let tiers = tiers(vec![1, 2, 3, 4]);
3302
3303 assert_eq!(
3305 represent_amount(
3306 Amount::from_sats(6),
3307 &starting,
3308 &tiers,
3309 3,
3310 &FeeConsensus::zero()
3311 ),
3312 denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
3313 );
3314
3315 assert_eq!(
3317 represent_amount(
3318 Amount::from_sats(6),
3319 &starting,
3320 &tiers,
3321 2,
3322 &FeeConsensus::zero()
3323 ),
3324 denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
3325 );
3326 }
3327
3328 #[test_log::test(tokio::test)]
3329 async fn select_notes_avg_test() {
3330 let max_amount = Amount::from_sats(1_000_000);
3331 let tiers = Tiered::gen_denominations(2, max_amount);
3332 let tiered = represent_amount::<()>(
3333 max_amount,
3334 &TieredCounts::default(),
3335 &tiers,
3336 3,
3337 &FeeConsensus::zero(),
3338 );
3339
3340 let mut total_notes = 0;
3341 for multiplier in 1..100 {
3342 let stream = reverse_sorted_note_stream(tiered.iter().collect());
3343 let select = select_notes_from_stream(
3344 stream,
3345 Amount::from_sats(multiplier * 1000),
3346 FeeConsensus::zero(),
3347 )
3348 .await;
3349 total_notes += select.unwrap().into_iter_items().count();
3350 }
3351 assert_eq!(total_notes / 100, 10);
3352 }
3353
3354 #[test_log::test(tokio::test)]
3355 async fn select_notes_returns_exact_amount_with_minimum_notes() {
3356 let f = || {
3357 reverse_sorted_note_stream(vec![
3358 (Amount::from_sats(1), 10),
3359 (Amount::from_sats(5), 10),
3360 (Amount::from_sats(20), 10),
3361 ])
3362 };
3363 assert_eq!(
3364 select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
3365 .await
3366 .unwrap(),
3367 notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
3368 );
3369 assert_eq!(
3370 select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
3371 .await
3372 .unwrap(),
3373 notes(vec![(Amount::from_sats(20), 1)])
3374 );
3375 }
3376
3377 #[test_log::test(tokio::test)]
3378 async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
3379 let stream = reverse_sorted_note_stream(vec![
3380 (Amount::from_sats(1), 1),
3381 (Amount::from_sats(5), 5),
3382 (Amount::from_sats(20), 5),
3383 ]);
3384 assert_eq!(
3385 select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
3386 .await
3387 .unwrap(),
3388 notes(vec![(Amount::from_sats(5), 2)])
3389 );
3390 }
3391
3392 #[test_log::test(tokio::test)]
3393 async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
3394 let stream = reverse_sorted_note_stream(vec![
3395 (Amount::from_sats(1), 3),
3396 (Amount::from_sats(5), 3),
3397 (Amount::from_sats(20), 2),
3398 ]);
3399 assert_eq!(
3400 select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
3401 .await
3402 .unwrap(),
3403 notes(vec![(Amount::from_sats(20), 2)])
3404 );
3405 }
3406
3407 #[test_log::test(tokio::test)]
3408 async fn select_notes_returns_error_if_amount_is_too_large() {
3409 let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
3410 let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
3411 .await
3412 .unwrap_err();
3413 assert_eq!(error.total_amount, Amount::from_sats(10));
3414 }
3415
3416 fn reverse_sorted_note_stream(
3417 notes: Vec<(Amount, usize)>,
3418 ) -> impl futures::Stream<Item = (Amount, String)> {
3419 futures::stream::iter(
3420 notes
3421 .into_iter()
3422 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
3424 .sorted()
3425 .rev(),
3426 )
3427 }
3428
3429 fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
3430 notes
3431 .into_iter()
3432 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
3433 .collect()
3434 }
3435
3436 #[test]
3437 fn decoding_empty_oob_notes_fails() {
3438 let empty_oob_notes =
3439 OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
3440 let oob_notes_string = empty_oob_notes.to_string();
3441
3442 let res = oob_notes_string.parse::<OOBNotes>();
3443
3444 assert!(res.is_err(), "An empty OOB notes string should not parse");
3445 }
3446
3447 fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
3448 where
3449 T: FromStr + Display + crate::Encodable + crate::Decodable,
3450 <T as FromStr>::Err: std::fmt::Debug,
3451 F: Fn(T),
3452 {
3453 let data_parsed = data.to_string().parse().expect("Deserialization failed");
3454
3455 assertions(data_parsed);
3456
3457 let data_parsed = crate::base32::encode_prefixed(FEDIMINT_PREFIX, &data)
3458 .parse()
3459 .expect("Deserialization failed");
3460
3461 assertions(data_parsed);
3462
3463 assertions(data);
3464 }
3465
3466 #[test]
3467 fn notes_encode_decode() {
3468 let federation_id_1 =
3469 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
3470 let federation_id_prefix_1 = federation_id_1.to_prefix();
3471 let federation_id_2 =
3472 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
3473 let federation_id_prefix_2 = federation_id_2.to_prefix();
3474
3475 let notes = vec![(
3476 Amount::from_sats(1),
3477 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
3478 )]
3479 .into_iter()
3480 .collect::<TieredMulti<_>>();
3481
3482 let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
3484 test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
3485 assert_eq!(oob_notes.notes(), ¬es);
3486 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3487 assert_eq!(oob_notes.federation_invite(), None);
3488 });
3489
3490 let invite = InviteCode::new(
3492 "wss://foo.bar".parse().unwrap(),
3493 PeerId::from(0),
3494 federation_id_1,
3495 None,
3496 );
3497 let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
3498 test_roundtrip_serialize_str(notes_invite, |oob_notes| {
3499 assert_eq!(oob_notes.notes(), ¬es);
3500 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3501 assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
3502 });
3503
3504 let notes_no_prefix = OOBNotes(vec![
3507 OOBNotesPart::Notes(notes.clone()),
3508 OOBNotesPart::Invite {
3509 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
3510 federation_id: federation_id_1,
3511 },
3512 ]);
3513 test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
3514 assert_eq!(oob_notes.notes(), ¬es);
3515 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3516 });
3517
3518 let notes_inconsistent = OOBNotes(vec![
3520 OOBNotesPart::Notes(notes),
3521 OOBNotesPart::Invite {
3522 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
3523 federation_id: federation_id_1,
3524 },
3525 OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
3526 ]);
3527 let notes_inconsistent_str = notes_inconsistent.to_string();
3528 assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
3529 }
3530
3531 #[test]
3532 fn spendable_note_undecoded_sanity() {
3533 #[allow(clippy::single_element_loop)]
3535 for note_hex in [
3536 "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
3537 ] {
3538 let note =
3539 SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
3540 let note_undecoded =
3541 SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
3542 .unwrap()
3543 .decode()
3544 .unwrap();
3545 assert_eq!(note, note_undecoded,);
3546 assert_eq!(
3547 serde_json::to_string(¬e).unwrap(),
3548 serde_json::to_string(¬e_undecoded).unwrap(),
3549 );
3550 }
3551 }
3552
3553 #[test]
3554 fn reissuance_meta_compatibility_02_03() {
3555 let dummy_outpoint = OutPoint {
3556 txid: TransactionId::all_zeros(),
3557 out_idx: 0,
3558 };
3559
3560 let old_meta_json = json!({
3561 "reissuance": {
3562 "out_point": dummy_outpoint
3563 }
3564 });
3565
3566 let old_meta: MintOperationMetaVariant =
3567 serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
3568 assert_eq!(
3569 old_meta,
3570 MintOperationMetaVariant::Reissuance {
3571 legacy_out_point: Some(dummy_outpoint),
3572 txid: None,
3573 out_point_indices: vec![],
3574 }
3575 );
3576
3577 let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
3578 legacy_out_point: None,
3579 txid: Some(dummy_outpoint.txid),
3580 out_point_indices: vec![0],
3581 })
3582 .expect("serializing always works");
3583 assert_eq!(
3584 new_meta_json,
3585 json!({
3586 "reissuance": {
3587 "txid": dummy_outpoint.txid,
3588 "out_point_indices": [dummy_outpoint.out_idx],
3589 }
3590 })
3591 );
3592 }
3593
3594 #[test]
3595 fn spend_oob_meta_no_timeout_defaults_to_false() {
3596 let notes = vec![(
3597 Amount::from_sats(1),
3598 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
3599 )]
3600 .into_iter()
3601 .collect::<TieredMulti<_>>();
3602 let oob_notes = OOBNotes::new(FederationId::dummy().to_prefix(), notes);
3603 let mut old_meta_json = serde_json::to_value(MintOperationMetaVariant::SpendOOB {
3604 requested_amount: Amount::from_sats(42),
3605 oob_notes: oob_notes.clone(),
3606 no_timeout: false,
3607 })
3608 .expect("serializing always works");
3609 old_meta_json
3610 .get_mut("spend_o_o_b")
3611 .expect("spend OOB variant should serialize as spend_o_o_b")
3612 .as_object_mut()
3613 .expect("spend OOB variant should serialize to an object")
3614 .remove("no_timeout");
3615 assert_eq!(
3616 old_meta_json,
3617 json!({
3618 "spend_o_o_b": {
3619 "requested_amount": Amount::from_sats(42),
3620 "oob_notes": oob_notes.clone(),
3621 }
3622 })
3623 );
3624
3625 let old_meta: MintOperationMetaVariant =
3626 serde_json::from_value(old_meta_json).expect("parsing old spend OOB meta failed");
3627 assert_eq!(
3628 old_meta,
3629 MintOperationMetaVariant::SpendOOB {
3630 requested_amount: Amount::from_sats(42),
3631 oob_notes,
3632 no_timeout: false,
3633 }
3634 );
3635 }
3636}