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