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<()> {
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(());
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 blind_nonces: Vec<BlindNonce> = finalized
650 .pending_notes
651 .iter()
652 .map(|(_, req)| BlindNonce(req.blinded_message()))
653 .collect();
654
655 let outpoints = if blind_nonces.is_empty() {
657 vec![]
658 } else {
659 args.module_api()
660 .fetch_blind_nonce_outpoints(blind_nonces)
661 .await
662 .context("Failed to fetch blind nonce outpoints")?
663 };
664
665 let state_machines: Vec<MintClientStateMachines> = finalized
667 .pending_notes
668 .into_iter()
669 .zip(outpoints)
670 .map(|((amount, issuance_request), out_point)| {
671 MintClientStateMachines::Output(MintOutputStateMachine {
672 common: MintOutputCommon {
673 operation_id: OperationId::new_random(),
674 out_point_range: OutPointRange::new_single(
675 out_point.txid,
676 out_point.out_idx,
677 )
678 .expect("Can't overflow"),
679 },
680 state: MintOutputStates::Created(output::MintOutputStatesCreated {
681 amount,
682 issuance_request,
683 }),
684 })
685 })
686 .collect();
687
688 let state_machines = args.context().map_dyn(state_machines).collect();
689
690 args.context()
691 .add_state_machines_dbtx(&mut dbtx.to_ref_nc(), state_machines)
692 .await?;
693
694 for (amount, note_idx) in finalized.next_note_idx {
696 dbtx.insert_entry(&NextECashNoteIndexKey(amount), ¬e_idx.as_u64())
697 .await;
698 }
699
700 dbtx.commit_tx().await;
701
702 return Ok(());
703 }
704
705 dbtx.commit_tx().await;
706
707 args.update_recovery_progress(RecoveryProgress {
708 complete: state.next_index.try_into().unwrap_or(u32::MAX),
709 total: state.total_items.try_into().unwrap_or(u32::MAX),
710 });
711 }
712 }
713}
714
715impl ModuleInit for MintClientInit {
716 type Common = MintCommonInit;
717
718 async fn dump_database(
719 &self,
720 dbtx: &mut DatabaseTransaction<'_>,
721 prefix_names: Vec<String>,
722 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
723 let mut mint_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
724 BTreeMap::new();
725 let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
726 prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
727 });
728
729 for table in filtered_prefixes {
730 match table {
731 DbKeyPrefix::Note => {
732 push_db_pair_items!(
733 dbtx,
734 NoteKeyPrefix,
735 NoteKey,
736 SpendableNoteUndecoded,
737 mint_client_items,
738 "Notes"
739 );
740 }
741 DbKeyPrefix::NextECashNoteIndex => {
742 push_db_pair_items!(
743 dbtx,
744 NextECashNoteIndexKeyPrefix,
745 NextECashNoteIndexKey,
746 u64,
747 mint_client_items,
748 "NextECashNoteIndex"
749 );
750 }
751 DbKeyPrefix::CancelledOOBSpend => {
752 push_db_pair_items!(
753 dbtx,
754 CancelledOOBSpendKeyPrefix,
755 CancelledOOBSpendKey,
756 (),
757 mint_client_items,
758 "CancelledOOBSpendKey"
759 );
760 }
761 DbKeyPrefix::RecoveryFinalized => {
762 if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await {
763 mint_client_items.insert("RecoveryFinalized".to_string(), Box::new(val));
764 }
765 }
766 DbKeyPrefix::RecoveryState
767 | DbKeyPrefix::ReusedNoteIndices
768 | DbKeyPrefix::RecoveryStateV2
769 | DbKeyPrefix::ExternalReservedStart
770 | DbKeyPrefix::CoreInternalReservedStart
771 | DbKeyPrefix::CoreInternalReservedEnd => {}
772 }
773 }
774
775 Box::new(mint_client_items.into_iter())
776 }
777}
778
779#[apply(async_trait_maybe_send!)]
780impl ClientModuleInit for MintClientInit {
781 type Module = MintClientModule;
782
783 fn supported_api_versions(&self) -> MultiApiVersion {
784 MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
785 .expect("no version conflicts")
786 }
787
788 async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
789 Ok(MintClientModule {
790 federation_id: *args.federation_id(),
791 cfg: args.cfg().clone(),
792 secret: args.module_root_secret().clone(),
793 secp: Secp256k1::new(),
794 notifier: args.notifier().clone(),
795 client_ctx: args.context(),
796 balance_update_sender: tokio::sync::watch::channel(()).0,
797 })
798 }
799
800 async fn recover(
801 &self,
802 args: &ClientModuleRecoverArgs<Self>,
803 snapshot: Option<&<Self::Module as ClientModule>::Backup>,
804 ) -> anyhow::Result<()> {
805 let mut dbtx = args.db().begin_transaction_nc().await;
806
807 if dbtx.get_value(&RecoveryStateV2Key).await.is_some() {
809 return self.recover_from_slices(args).await;
810 }
811
812 if dbtx.get_value(&RecoveryStateKey).await.is_some() {
814 return args
815 .recover_from_history::<MintRecovery>(self, snapshot)
816 .await;
817 }
818
819 if args.module_api().fetch_recovery_count().await.is_ok() {
822 self.recover_from_slices(args).await
824 } else {
825 args.recover_from_history::<MintRecovery>(self, snapshot)
827 .await
828 }
829 }
830
831 fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
832 let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = BTreeMap::new();
833 migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
834 Box::pin(migrate_to_v1(dbtx))
835 });
836 migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
837 Box::pin(async { migrate_state(active_states, inactive_states, migrate_state_to_v2) })
838 });
839
840 migrations
841 }
842
843 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
844 Some(
845 DbKeyPrefix::iter()
846 .map(|p| p as u8)
847 .chain(
848 DbKeyPrefix::ExternalReservedStart as u8
849 ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
850 )
851 .collect(),
852 )
853 }
854}
855
856pub struct MintClientModule {
882 federation_id: FederationId,
883 cfg: MintClientConfig,
884 secret: DerivableSecret,
885 secp: Secp256k1<All>,
886 notifier: ModuleNotifier<MintClientStateMachines>,
887 pub client_ctx: ClientContext<Self>,
888 balance_update_sender: tokio::sync::watch::Sender<()>,
889}
890
891impl fmt::Debug for MintClientModule {
892 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
893 f.debug_struct("MintClientModule")
894 .field("federation_id", &self.federation_id)
895 .field("cfg", &self.cfg)
896 .field("notifier", &self.notifier)
897 .field("client_ctx", &self.client_ctx)
898 .finish_non_exhaustive()
899 }
900}
901
902#[derive(Clone)]
904pub struct MintClientContext {
905 pub federation_id: FederationId,
906 pub client_ctx: ClientContext<MintClientModule>,
907 pub mint_decoder: Decoder,
908 pub tbs_pks: Tiered<AggregatePublicKey>,
909 pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>,
910 pub secret: DerivableSecret,
911 pub module_db: Database,
914 pub balance_update_sender: tokio::sync::watch::Sender<()>,
916}
917
918impl fmt::Debug for MintClientContext {
919 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
920 f.debug_struct("MintClientContext")
921 .field("federation_id", &self.federation_id)
922 .finish_non_exhaustive()
923 }
924}
925
926impl MintClientContext {
927 fn await_cancel_oob_payment(&self, operation_id: OperationId) -> BoxFuture<'static, ()> {
928 let db = self.module_db.clone();
929 Box::pin(async move {
930 db.wait_key_exists(&CancelledOOBSpendKey(operation_id))
931 .await;
932 })
933 }
934}
935
936impl Context for MintClientContext {
937 const KIND: Option<ModuleKind> = Some(KIND);
938}
939
940#[apply(async_trait_maybe_send!)]
941impl ClientModule for MintClientModule {
942 type Init = MintClientInit;
943 type Common = MintModuleTypes;
944 type Backup = EcashBackup;
945 type ModuleStateMachineContext = MintClientContext;
946 type States = MintClientStateMachines;
947
948 fn context(&self) -> Self::ModuleStateMachineContext {
949 MintClientContext {
950 federation_id: self.federation_id,
951 client_ctx: self.client_ctx.clone(),
952 mint_decoder: self.decoder(),
953 tbs_pks: self.cfg.tbs_pks.clone(),
954 peer_tbs_pks: self.cfg.peer_tbs_pks.clone(),
955 secret: self.secret.clone(),
956 module_db: self.client_ctx.module_db().clone(),
957 balance_update_sender: self.balance_update_sender.clone(),
958 }
959 }
960
961 fn input_fee(
962 &self,
963 amount: &Amounts,
964 _input: &<Self::Common as ModuleCommon>::Input,
965 ) -> Option<Amounts> {
966 Some(Amounts::new_bitcoin(
967 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
968 ))
969 }
970
971 fn output_fee(
972 &self,
973 amount: &Amounts,
974 _output: &<Self::Common as ModuleCommon>::Output,
975 ) -> Option<Amounts> {
976 Some(Amounts::new_bitcoin(
977 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
978 ))
979 }
980
981 #[cfg(feature = "cli")]
982 async fn handle_cli_command(
983 &self,
984 args: &[std::ffi::OsString],
985 ) -> anyhow::Result<serde_json::Value> {
986 cli::handle_cli_command(self, args).await
987 }
988
989 fn supports_backup(&self) -> bool {
990 true
991 }
992
993 async fn backup(&self) -> anyhow::Result<EcashBackup> {
994 self.client_ctx
995 .module_db()
996 .autocommit(
997 |dbtx_ctx, _| {
998 Box::pin(async { self.prepare_plaintext_ecash_backup(dbtx_ctx).await })
999 },
1000 None,
1001 )
1002 .await
1003 .map_err(|e| match e {
1004 AutocommitError::ClosureError { error, .. } => error,
1005 AutocommitError::CommitFailed { last_error, .. } => {
1006 anyhow!("Commit to DB failed: {last_error}")
1007 }
1008 })
1009 }
1010
1011 fn supports_being_primary(&self) -> PrimaryModuleSupport {
1012 PrimaryModuleSupport::selected(PrimaryModulePriority::HIGH, [AmountUnit::BITCOIN])
1013 }
1014
1015 async fn create_final_inputs_and_outputs(
1016 &self,
1017 dbtx: &mut DatabaseTransaction<'_>,
1018 operation_id: OperationId,
1019 unit: AmountUnit,
1020 mut input_amount: Amount,
1021 mut output_amount: Amount,
1022 ) -> anyhow::Result<(
1023 ClientInputBundle<MintInput, MintClientStateMachines>,
1024 ClientOutputBundle<MintOutput, MintClientStateMachines>,
1025 )> {
1026 let consolidation_inputs = self.consolidate_notes(dbtx).await?;
1027
1028 if unit != AmountUnit::BITCOIN {
1029 bail!("Module can only handle Bitcoin");
1030 }
1031
1032 input_amount += consolidation_inputs
1033 .iter()
1034 .map(|input| input.0.amounts.get_bitcoin())
1035 .sum();
1036
1037 output_amount += consolidation_inputs
1038 .iter()
1039 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
1040 .sum();
1041
1042 let additional_inputs = self
1043 .create_sufficient_input(dbtx, output_amount.saturating_sub(input_amount))
1044 .await?;
1045
1046 input_amount += additional_inputs
1047 .iter()
1048 .map(|input| input.0.amounts.get_bitcoin())
1049 .sum();
1050
1051 output_amount += additional_inputs
1052 .iter()
1053 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
1054 .sum();
1055
1056 let outputs = self
1057 .create_output(
1058 dbtx,
1059 operation_id,
1060 2,
1061 input_amount.saturating_sub(output_amount),
1062 )
1063 .await;
1064
1065 Ok((
1066 create_bundle_for_inputs(
1067 [consolidation_inputs, additional_inputs].concat(),
1068 operation_id,
1069 ),
1070 outputs,
1071 ))
1072 }
1073
1074 async fn await_primary_module_output(
1075 &self,
1076 operation_id: OperationId,
1077 out_point: OutPoint,
1078 ) -> anyhow::Result<()> {
1079 self.await_output_finalized(operation_id, out_point).await
1080 }
1081
1082 async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
1083 if unit != AmountUnit::BITCOIN {
1084 return Amount::ZERO;
1085 }
1086 self.get_note_counts_by_denomination(dbtx)
1087 .await
1088 .total_amount()
1089 }
1090
1091 async fn get_balances(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
1092 Amounts::new_bitcoin(
1093 <Self as ClientModule>::get_balance(self, dbtx, AmountUnit::BITCOIN).await,
1094 )
1095 }
1096
1097 async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
1098 Box::pin(tokio_stream::wrappers::WatchStream::new(
1099 self.balance_update_sender.subscribe(),
1100 ))
1101 }
1102
1103 async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
1104 let balance = ClientModule::get_balances(self, dbtx).await;
1105
1106 for (unit, amount) in balance {
1107 if Amount::from_units(0) < amount {
1108 bail!("Outstanding balance: {amount}, unit: {unit:?}");
1109 }
1110 }
1111
1112 if !self.client_ctx.get_own_active_states().await.is_empty() {
1113 bail!("Pending operations")
1114 }
1115 Ok(())
1116 }
1117
1118 async fn handle_rpc(
1119 &self,
1120 method: String,
1121 request: serde_json::Value,
1122 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
1123 Box::pin(try_stream! {
1124 match method.as_str() {
1125 "reissue_external_notes" => {
1126 let req: ReissueExternalNotesRequest = serde_json::from_value(request)?;
1127 let result = self.reissue_external_notes(req.oob_notes, req.extra_meta).await?;
1128 yield serde_json::to_value(result)?;
1129 }
1130 "subscribe_reissue_external_notes" => {
1131 let req: SubscribeReissueExternalNotesRequest = serde_json::from_value(request)?;
1132 let stream = self.subscribe_reissue_external_notes(req.operation_id).await?;
1133 for await state in stream.into_stream() {
1134 yield serde_json::to_value(state)?;
1135 }
1136 }
1137 "spend_notes" => {
1138 let req: SpendNotesRequest = serde_json::from_value(request)?;
1139 let result = self.spend_notes_with_selector(
1140 &SelectNotesWithExactAmount,
1141 req.amount,
1142 req.try_cancel_after,
1143 req.include_invite,
1144 req.extra_meta
1145 ).await?;
1146 yield serde_json::to_value(result)?;
1147 }
1148 "spend_notes_expert" => {
1149 let req: SpendNotesExpertRequest = serde_json::from_value(request)?;
1150 let result = self.spend_notes_with_selector(
1151 &SelectNotesWithAtleastAmount,
1152 req.min_amount,
1153 req.try_cancel_after,
1154 req.include_invite,
1155 req.extra_meta
1156 ).await?;
1157 yield serde_json::to_value(result)?;
1158 }
1159 "validate_notes" => {
1160 let req: ValidateNotesRequest = serde_json::from_value(request)?;
1161 let result = self.validate_notes(&req.oob_notes)?;
1162 yield serde_json::to_value(result)?;
1163 }
1164 "try_cancel_spend_notes" => {
1165 let req: TryCancelSpendNotesRequest = serde_json::from_value(request)?;
1166 let result = self.try_cancel_spend_notes(req.operation_id).await;
1167 yield serde_json::to_value(result)?;
1168 }
1169 "subscribe_spend_notes" => {
1170 let req: SubscribeSpendNotesRequest = serde_json::from_value(request)?;
1171 let stream = self.subscribe_spend_notes(req.operation_id).await?;
1172 for await state in stream.into_stream() {
1173 yield serde_json::to_value(state)?;
1174 }
1175 }
1176 "await_spend_oob_refund" => {
1177 let req: AwaitSpendOobRefundRequest = serde_json::from_value(request)?;
1178 let value = self.await_spend_oob_refund(req.operation_id).await;
1179 yield serde_json::to_value(value)?;
1180 }
1181 "note_counts_by_denomination" => {
1182 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1183 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
1184 yield serde_json::to_value(note_counts)?;
1185 }
1186 _ => {
1187 Err(anyhow::format_err!("Unknown method: {method}"))?;
1188 unreachable!()
1189 },
1190 }
1191 })
1192 }
1193}
1194
1195#[derive(Deserialize)]
1196struct ReissueExternalNotesRequest {
1197 oob_notes: OOBNotes,
1198 extra_meta: serde_json::Value,
1199}
1200
1201#[derive(Deserialize)]
1202struct SubscribeReissueExternalNotesRequest {
1203 operation_id: OperationId,
1204}
1205
1206#[derive(Deserialize)]
1209struct SpendNotesExpertRequest {
1210 min_amount: Amount,
1211 try_cancel_after: Option<Duration>,
1212 include_invite: bool,
1213 extra_meta: serde_json::Value,
1214}
1215
1216#[derive(Deserialize)]
1217struct SpendNotesRequest {
1218 amount: Amount,
1219 try_cancel_after: Option<Duration>,
1220 include_invite: bool,
1221 extra_meta: serde_json::Value,
1222}
1223
1224#[derive(Deserialize)]
1225struct ValidateNotesRequest {
1226 oob_notes: OOBNotes,
1227}
1228
1229#[derive(Deserialize)]
1230struct TryCancelSpendNotesRequest {
1231 operation_id: OperationId,
1232}
1233
1234#[derive(Deserialize)]
1235struct SubscribeSpendNotesRequest {
1236 operation_id: OperationId,
1237}
1238
1239#[derive(Deserialize)]
1240struct AwaitSpendOobRefundRequest {
1241 operation_id: OperationId,
1242}
1243
1244#[derive(thiserror::Error, Debug, Clone)]
1245pub enum ReissueExternalNotesError {
1246 #[error("Federation ID does not match")]
1247 WrongFederationId,
1248 #[error("We already reissued these notes")]
1249 AlreadyReissued,
1250}
1251
1252impl MintClientModule {
1253 async fn create_sufficient_input(
1254 &self,
1255 dbtx: &mut DatabaseTransaction<'_>,
1256 min_amount: Amount,
1257 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1258 if min_amount == Amount::ZERO {
1259 return Ok(vec![]);
1260 }
1261
1262 let selected_notes = Self::select_notes(
1263 dbtx,
1264 &SelectNotesWithAtleastAmount,
1265 min_amount,
1266 self.cfg.fee_consensus.clone(),
1267 )
1268 .await?;
1269
1270 for (amount, note) in selected_notes.iter_items() {
1271 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as sufficient input to fund a tx");
1272 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1273 }
1274
1275 let sender = self.balance_update_sender.clone();
1276 dbtx.on_commit(move || sender.send_replace(()));
1277
1278 let inputs = self.create_input_from_notes(selected_notes)?;
1279
1280 assert!(!inputs.is_empty());
1281
1282 Ok(inputs)
1283 }
1284
1285 #[deprecated(
1287 since = "0.5.0",
1288 note = "Use `get_note_counts_by_denomination` instead"
1289 )]
1290 pub async fn get_notes_tier_counts(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1291 self.get_note_counts_by_denomination(dbtx).await
1292 }
1293
1294 pub async fn get_available_notes_by_tier_counts(
1298 &self,
1299 dbtx: &mut DatabaseTransaction<'_>,
1300 counts: TieredCounts,
1301 ) -> (TieredMulti<SpendableNoteUndecoded>, TieredCounts) {
1302 dbtx.find_by_prefix(&NoteKeyPrefix)
1303 .await
1304 .fold(
1305 (TieredMulti::<SpendableNoteUndecoded>::default(), counts),
1306 |(mut notes, mut counts), (key, note)| async move {
1307 let amount = key.amount;
1308 if 0 < counts.get(amount) {
1309 counts.dec(amount);
1310 notes.push(amount, note);
1311 }
1312
1313 (notes, counts)
1314 },
1315 )
1316 .await
1317 }
1318
1319 pub async fn create_output(
1324 &self,
1325 dbtx: &mut DatabaseTransaction<'_>,
1326 operation_id: OperationId,
1327 notes_per_denomination: u16,
1328 exact_amount: Amount,
1329 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1330 if exact_amount == Amount::ZERO {
1331 return ClientOutputBundle::new(vec![], vec![]);
1332 }
1333
1334 let denominations = represent_amount(
1337 exact_amount,
1338 &self.get_note_counts_by_denomination(dbtx).await,
1339 &self.cfg.tbs_pks,
1340 notes_per_denomination,
1341 &self.cfg.fee_consensus,
1342 );
1343
1344 self.create_output_for_denominations(dbtx, operation_id, denominations)
1345 .await
1346 }
1347
1348 async fn create_exact_output(
1360 &self,
1361 dbtx: &mut DatabaseTransaction<'_>,
1362 operation_id: OperationId,
1363 amount: Amount,
1364 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1365 if amount == Amount::ZERO {
1366 return ClientOutputBundle::new(vec![], vec![]);
1367 }
1368
1369 self.create_output_for_denominations(
1370 dbtx,
1371 operation_id,
1372 self.represent_exact_amount(amount),
1373 )
1374 .await
1375 }
1376
1377 fn represent_exact_amount(&self, amount: Amount) -> TieredCounts {
1383 represent_amount(
1384 amount,
1385 &TieredCounts::default(),
1386 &self.cfg.tbs_pks,
1387 0,
1388 &FeeConsensus::zero(),
1389 )
1390 }
1391
1392 async fn create_output_for_denominations(
1393 &self,
1394 dbtx: &mut DatabaseTransaction<'_>,
1395 operation_id: OperationId,
1396 denominations: TieredCounts,
1397 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1398 let mut outputs = Vec::new();
1399 let mut issuance_requests = Vec::new();
1400
1401 for (amount, num) in denominations.iter() {
1402 for _ in 0..num {
1403 let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await;
1404
1405 debug!(
1406 %amount,
1407 "Generated issuance request"
1408 );
1409
1410 outputs.push(ClientOutput {
1411 output: MintOutput::new_v0(amount, blind_nonce),
1412 amounts: Amounts::new_bitcoin(amount),
1413 });
1414
1415 issuance_requests.push((amount, issuance_request));
1416 }
1417 }
1418
1419 let state_generator = Arc::new(move |out_point_range: OutPointRange| {
1420 assert_eq!(out_point_range.count(), issuance_requests.len());
1421 vec![MintClientStateMachines::Output(MintOutputStateMachine {
1422 common: MintOutputCommon {
1423 operation_id,
1424 out_point_range,
1425 },
1426 state: MintOutputStates::CreatedMulti(MintOutputStatesCreatedMulti {
1427 issuance_requests: out_point_range
1428 .into_iter()
1429 .map(|out_point| out_point.out_idx)
1430 .zip(issuance_requests.clone())
1431 .collect(),
1432 }),
1433 })]
1434 });
1435
1436 ClientOutputBundle::new(
1437 outputs,
1438 vec![ClientOutputSM {
1439 state_machines: state_generator,
1440 }],
1441 )
1442 }
1443
1444 pub async fn get_note_counts_by_denomination(
1446 &self,
1447 dbtx: &mut DatabaseTransaction<'_>,
1448 ) -> TieredCounts {
1449 dbtx.find_by_prefix(&NoteKeyPrefix)
1450 .await
1451 .fold(
1452 TieredCounts::default(),
1453 |mut acc, (key, _note)| async move {
1454 acc.inc(key.amount, 1);
1455 acc
1456 },
1457 )
1458 .await
1459 }
1460
1461 #[deprecated(
1463 since = "0.5.0",
1464 note = "Use `get_note_counts_by_denomination` instead"
1465 )]
1466 pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1467 self.get_note_counts_by_denomination(dbtx).await
1468 }
1469
1470 pub async fn estimate_spend_all_fees(&self) -> Amount {
1476 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1477 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
1478
1479 note_counts
1480 .iter()
1481 .filter_map(|(amount, count)| {
1482 let note_fee = self.cfg.fee_consensus.fee(amount);
1483 if note_fee < amount {
1484 note_fee.checked_mul(count as u64)
1485 } else {
1486 None
1487 }
1488 })
1489 .fold(Amount::ZERO, |acc, fee| {
1490 acc.checked_add(fee).expect("fee sum overflow")
1491 })
1492 }
1493
1494 pub async fn await_output_finalized(
1498 &self,
1499 operation_id: OperationId,
1500 out_point: OutPoint,
1501 ) -> anyhow::Result<()> {
1502 let stream = self
1503 .notifier
1504 .subscribe(operation_id)
1505 .await
1506 .filter_map(|state| async {
1507 let MintClientStateMachines::Output(state) = state else {
1508 return None;
1509 };
1510
1511 if state.common.txid() != out_point.txid
1512 || !state
1513 .common
1514 .out_point_range
1515 .out_idx_iter()
1516 .contains(&out_point.out_idx)
1517 {
1518 return None;
1519 }
1520
1521 match state.state {
1522 MintOutputStates::Succeeded(_) => Some(Ok(())),
1523 MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
1524 MintOutputStates::Failed(failed) => Some(Err(anyhow!(
1525 "Failed to finalize transaction: {}",
1526 failed.error
1527 ))),
1528 MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1529 }
1530 });
1531 pin_mut!(stream);
1532
1533 stream.next_or_pending().await
1534 }
1535
1536 pub async fn consolidate_notes(
1543 &self,
1544 dbtx: &mut DatabaseTransaction<'_>,
1545 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1546 const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1549 const MIN_NOTES_PER_TIER: usize = 4;
1551 const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1554 #[allow(clippy::assertions_on_constants)]
1556 {
1557 assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1558 }
1559
1560 let counts = self.get_note_counts_by_denomination(dbtx).await;
1561
1562 let should_consolidate = counts
1563 .iter()
1564 .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1565
1566 if !should_consolidate {
1567 return Ok(vec![]);
1568 }
1569
1570 let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1571
1572 let excessive_counts: TieredCounts = counts
1573 .iter()
1574 .map(|(amount, count)| {
1575 let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1576
1577 max_count -= take;
1578 (amount, take)
1579 })
1580 .collect();
1581
1582 let (selected_notes, unavailable) = self
1583 .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1584 .await;
1585
1586 debug_assert!(
1587 unavailable.is_empty(),
1588 "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1589 );
1590
1591 if !selected_notes.is_empty() {
1592 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");
1593 }
1594
1595 let mut selected_notes_decoded = vec![];
1596 for (amount, note) in selected_notes.iter_items() {
1597 let spendable_note_decoded = note.decode()?;
1598 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1599 Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1600 .await;
1601 selected_notes_decoded.push((amount, spendable_note_decoded));
1602 }
1603
1604 let sender = self.balance_update_sender.clone();
1605 dbtx.on_commit(move || sender.send_replace(()));
1606
1607 self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1608 }
1609
1610 #[allow(clippy::type_complexity)]
1612 pub fn create_input_from_notes(
1613 &self,
1614 notes: TieredMulti<SpendableNote>,
1615 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1616 let mut inputs_and_notes = Vec::new();
1617
1618 for (amount, spendable_note) in notes.into_iter_items() {
1619 let key = self
1620 .cfg
1621 .tbs_pks
1622 .get(amount)
1623 .ok_or(anyhow!("Invalid amount tier: {amount}"))?;
1624
1625 let note = spendable_note.note();
1626
1627 if !note.verify(*key) {
1628 bail!("Invalid note");
1629 }
1630
1631 inputs_and_notes.push((
1632 ClientInput {
1633 input: MintInput::new_v0(amount, note),
1634 keys: vec![spendable_note.spend_key],
1635 amounts: Amounts::new_bitcoin(amount),
1636 },
1637 spendable_note,
1638 ));
1639 }
1640
1641 Ok(inputs_and_notes)
1642 }
1643
1644 async fn spend_notes_oob(
1645 &self,
1646 dbtx: &mut DatabaseTransaction<'_>,
1647 notes_selector: &impl NotesSelector,
1648 amount: Amount,
1649 try_cancel_after: Option<Duration>,
1650 ) -> anyhow::Result<(
1651 OperationId,
1652 Vec<MintClientStateMachines>,
1653 TieredMulti<SpendableNote>,
1654 )> {
1655 ensure!(
1656 amount > Amount::ZERO,
1657 "zero-amount out-of-band spends are not supported"
1658 );
1659
1660 let selected_notes =
1661 Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1662
1663 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1664
1665 for (amount, note) in selected_notes.iter_items() {
1666 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1667 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1668 }
1669
1670 let sender = self.balance_update_sender.clone();
1671 dbtx.on_commit(move || sender.send_replace(()));
1672
1673 let try_cancel_after = try_cancel_after.unwrap_or(OOB_SPEND_NO_TIMEOUT);
1674 let state_machines = if try_cancel_after == OOB_SPEND_NO_TIMEOUT {
1675 vec![]
1676 } else {
1677 vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1678 operation_id,
1679 state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1680 spendable_notes: selected_notes.clone().into_iter_items().collect(),
1681 timeout: fedimint_core::time::now() + try_cancel_after,
1682 }),
1683 })]
1684 };
1685
1686 Ok((operation_id, state_machines, selected_notes))
1687 }
1688
1689 async fn is_no_timeout_oob_spend(&self, operation_id: OperationId) -> anyhow::Result<bool> {
1690 let operation = self.mint_operation(operation_id).await?;
1691 let MintOperationMetaVariant::SpendOOB { no_timeout, .. } =
1692 operation.meta::<MintOperationMeta>().variant
1693 else {
1694 bail!("Operation is not a out-of-band spend");
1695 };
1696
1697 Ok(no_timeout)
1698 }
1699
1700 pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1701 if self
1702 .is_no_timeout_oob_spend(operation_id)
1703 .await
1704 .unwrap_or(false)
1705 {
1706 return SpendOOBRefund {
1707 user_triggered: false,
1708 transaction_ids: vec![],
1709 };
1710 }
1711
1712 Box::pin(
1713 self.notifier
1714 .subscribe(operation_id)
1715 .await
1716 .filter_map(|state| async {
1717 let MintClientStateMachines::OOB(state) = state else {
1718 return None;
1719 };
1720
1721 match state.state {
1722 MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1723 user_triggered: false,
1724 transaction_ids: vec![refund.refund_txid],
1725 }),
1726 MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1727 user_triggered: true,
1728 transaction_ids: vec![refund.refund_txid],
1729 }),
1730 MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1731 user_triggered: true,
1732 transaction_ids: vec![refund.refund_txid],
1733 }),
1734 MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1735 }
1736 }),
1737 )
1738 .next_or_pending()
1739 .await
1740 }
1741
1742 async fn select_notes(
1744 dbtx: &mut DatabaseTransaction<'_>,
1745 notes_selector: &impl NotesSelector,
1746 requested_amount: Amount,
1747 fee_consensus: FeeConsensus,
1748 ) -> anyhow::Result<TieredMulti<SpendableNote>> {
1749 let note_stream = dbtx
1750 .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1751 .await
1752 .map(|(key, note)| (key.amount, note));
1753
1754 notes_selector
1755 .select_notes(note_stream, requested_amount, fee_consensus)
1756 .await?
1757 .into_iter_items()
1758 .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1759 .collect::<anyhow::Result<TieredMulti<_>>>()
1760 }
1761
1762 async fn get_all_spendable_notes(
1763 dbtx: &mut DatabaseTransaction<'_>,
1764 ) -> TieredMulti<SpendableNoteUndecoded> {
1765 (dbtx
1766 .find_by_prefix(&NoteKeyPrefix)
1767 .await
1768 .map(|(key, note)| (key.amount, note))
1769 .collect::<Vec<_>>()
1770 .await)
1771 .into_iter()
1772 .collect()
1773 }
1774
1775 async fn get_next_note_index(
1776 &self,
1777 dbtx: &mut DatabaseTransaction<'_>,
1778 amount: Amount,
1779 ) -> NoteIndex {
1780 NoteIndex(
1781 dbtx.get_value(&NextECashNoteIndexKey(amount))
1782 .await
1783 .unwrap_or(0),
1784 )
1785 }
1786
1787 pub fn new_note_secret_static(
1803 secret: &DerivableSecret,
1804 amount: Amount,
1805 note_idx: NoteIndex,
1806 ) -> DerivableSecret {
1807 assert_eq!(secret.level(), 2);
1808 debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1809 secret
1810 .child_key(MINT_E_CASH_TYPE_CHILD_ID) .child_key(ChildId(note_idx.as_u64()))
1812 .child_key(ChildId(amount.msats))
1813 }
1814
1815 async fn new_note_secret(
1819 &self,
1820 amount: Amount,
1821 dbtx: &mut DatabaseTransaction<'_>,
1822 ) -> DerivableSecret {
1823 let new_idx = self.get_next_note_index(dbtx, amount).await;
1824 dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1825 .await;
1826 Self::new_note_secret_static(&self.secret, amount, new_idx)
1827 }
1828
1829 pub async fn new_ecash_note(
1830 &self,
1831 amount: Amount,
1832 dbtx: &mut DatabaseTransaction<'_>,
1833 ) -> (NoteIssuanceRequest, BlindNonce) {
1834 let secret = self.new_note_secret(amount, dbtx).await;
1835 NoteIssuanceRequest::new(&self.secp, &secret)
1836 }
1837
1838 pub async fn reissue_fee_quote(&self, oob_notes: &OOBNotes) -> anyhow::Result<FeeQuote> {
1848 let input_amount = oob_notes.total_amount();
1853 let input_fee: Amount = oob_notes
1854 .notes()
1855 .iter_items()
1856 .map(|(amount, _)| self.cfg.fee_consensus.fee(amount))
1857 .sum();
1858
1859 self.client_ctx
1860 .fee_quote(
1861 OperationId::new_random(),
1862 FeeQuoteRequest {
1863 input_amount: Amounts::new_bitcoin(input_amount),
1864 output_amount: Amounts::ZERO,
1865 input_fee: Amounts::new_bitcoin(input_fee),
1866 output_fee: Amounts::ZERO,
1867 },
1868 )
1869 .await
1870 }
1871
1872 pub async fn send_fee_quote(&self, amount: Amount) -> anyhow::Result<FeeQuote> {
1886 let amount = self.cfg.fee_consensus.round_up(amount);
1887
1888 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1889
1890 if Self::select_notes(
1894 &mut dbtx,
1895 &SelectNotesWithExactAmount,
1896 amount,
1897 FeeConsensus::zero(),
1898 )
1899 .await
1900 .is_ok()
1901 {
1902 return Ok(FeeQuote::ZERO);
1903 }
1904
1905 drop(dbtx);
1906
1907 let denominations = self.represent_exact_amount(amount);
1913
1914 let output_amount = denominations.total_amount();
1915 let output_fee: Amount = denominations
1916 .iter()
1917 .map(|(denomination, count)| self.cfg.fee_consensus.fee(denomination) * count as u64)
1918 .sum();
1919
1920 self.client_ctx
1921 .fee_quote(
1922 OperationId::new_random(),
1923 FeeQuoteRequest {
1924 input_amount: Amounts::ZERO,
1925 output_amount: Amounts::new_bitcoin(output_amount),
1926 input_fee: Amounts::ZERO,
1927 output_fee: Amounts::new_bitcoin(output_fee),
1928 },
1929 )
1930 .await
1931 }
1932
1933 pub async fn reissue_external_notes<M: Serialize + Send>(
1938 &self,
1939 oob_notes: OOBNotes,
1940 extra_meta: M,
1941 ) -> anyhow::Result<OperationId> {
1942 let notes = oob_notes.notes().clone();
1943 let federation_id_prefix = oob_notes.federation_id_prefix();
1944
1945 debug!(
1946 target: LOG_CLIENT_MODULE_MINT,
1947 notes = ?notes
1948 .iter_items()
1949 .map(|(amount, note)| (amount, note.nonce()))
1950 .collect::<Vec<_>>(),
1951 "Reissuing external notes"
1952 );
1953
1954 ensure!(
1955 notes.total_amount() > Amount::ZERO,
1956 "Reissuing zero-amount e-cash isn't supported"
1957 );
1958
1959 if federation_id_prefix != self.federation_id.to_prefix() {
1960 bail!(ReissueExternalNotesError::WrongFederationId);
1961 }
1962
1963 let operation_id = OperationId(
1964 notes
1965 .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
1966 .to_byte_array(),
1967 );
1968
1969 let amount = notes.total_amount();
1970 let mint_inputs = self.create_input_from_notes(notes)?;
1971
1972 let tx = TransactionBuilder::new().with_inputs(
1973 self.client_ctx
1974 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
1975 );
1976
1977 let extra_meta = serde_json::to_value(extra_meta)
1978 .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
1979 let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
1980 variant: MintOperationMetaVariant::Reissuance {
1981 legacy_out_point: None,
1982 txid: Some(change_range.txid()),
1983 out_point_indices: change_range
1984 .into_iter()
1985 .map(|out_point| out_point.out_idx)
1986 .collect(),
1987 },
1988 amount,
1989 extra_meta: extra_meta.clone(),
1990 };
1991
1992 self.client_ctx
1993 .finalize_and_submit_transaction(
1994 operation_id,
1995 MintCommonInit::KIND.as_str(),
1996 operation_meta_gen,
1997 tx,
1998 )
1999 .await
2000 .context(ReissueExternalNotesError::AlreadyReissued)?;
2001
2002 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2003
2004 self.client_ctx
2005 .log_event(&mut dbtx, OOBNotesReissued { amount })
2006 .await;
2007
2008 self.client_ctx
2009 .log_event(
2010 &mut dbtx,
2011 ReceivePaymentEvent {
2012 operation_id,
2013 amount,
2014 },
2015 )
2016 .await;
2017
2018 dbtx.commit_tx().await;
2019
2020 Ok(operation_id)
2021 }
2022
2023 pub async fn subscribe_reissue_external_notes(
2026 &self,
2027 operation_id: OperationId,
2028 ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
2029 let operation = self.mint_operation(operation_id).await?;
2030 let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
2031 MintOperationMetaVariant::Reissuance {
2032 legacy_out_point,
2033 txid,
2034 out_point_indices,
2035 } => {
2036 let txid = txid
2039 .or(legacy_out_point.map(|out_point| out_point.txid))
2040 .context("Empty reissuance not permitted, this should never happen")?;
2041
2042 let out_points = out_point_indices
2043 .into_iter()
2044 .map(|out_idx| OutPoint { txid, out_idx })
2045 .chain(legacy_out_point)
2046 .collect::<Vec<_>>();
2047
2048 (txid, out_points)
2049 }
2050 MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"),
2051 };
2052
2053 let client_ctx = self.client_ctx.clone();
2054
2055 Ok(self.client_ctx.outcome_or_updates(operation, operation_id, move || {
2056 stream! {
2057 yield ReissueExternalNotesState::Created;
2058
2059 match client_ctx
2060 .transaction_updates(operation_id)
2061 .await
2062 .await_tx_accepted(txid)
2063 .await
2064 {
2065 Ok(()) => {
2066 yield ReissueExternalNotesState::Issuing;
2067 }
2068 Err(e) => {
2069 yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
2070 return;
2071 }
2072 }
2073
2074 for out_point in out_points {
2075 if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
2076 yield ReissueExternalNotesState::Failed(e.to_string());
2077 return;
2078 }
2079 }
2080 yield ReissueExternalNotesState::Done;
2081 }}
2082 ))
2083 }
2084
2085 #[deprecated(
2098 since = "0.5.0",
2099 note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
2100 )]
2101 pub async fn spend_notes<M: Serialize + Send>(
2102 &self,
2103 min_amount: Amount,
2104 try_cancel_after: Option<Duration>,
2105 include_invite: bool,
2106 extra_meta: M,
2107 ) -> anyhow::Result<(OperationId, OOBNotes)> {
2108 self.spend_notes_with_selector(
2109 &SelectNotesWithAtleastAmount,
2110 min_amount,
2111 try_cancel_after,
2112 include_invite,
2113 extra_meta,
2114 )
2115 .await
2116 }
2117
2118 pub async fn spend_notes_with_selector<M: Serialize + Send>(
2135 &self,
2136 notes_selector: &impl NotesSelector,
2137 requested_amount: Amount,
2138 try_cancel_after: Option<Duration>,
2139 include_invite: bool,
2140 extra_meta: M,
2141 ) -> anyhow::Result<(OperationId, OOBNotes)> {
2142 let federation_id_prefix = self.federation_id.to_prefix();
2143 let extra_meta = serde_json::to_value(extra_meta)
2144 .expect("MintClientModule::spend_notes extra_meta is serializable");
2145
2146 self.client_ctx
2147 .module_db()
2148 .autocommit(
2149 |dbtx, _| {
2150 let extra_meta = extra_meta.clone();
2151 Box::pin(async {
2152 let no_timeout = try_cancel_after.is_none();
2153 let (operation_id, states, notes) = self
2154 .spend_notes_oob(
2155 dbtx,
2156 notes_selector,
2157 requested_amount,
2158 try_cancel_after,
2159 )
2160 .await?;
2161
2162 let oob_notes = if include_invite {
2163 OOBNotes::new_with_invite(
2164 notes,
2165 &self.client_ctx.get_invite_code().await,
2166 )
2167 } else {
2168 OOBNotes::new(federation_id_prefix, notes)
2169 };
2170
2171 self.client_ctx
2172 .add_state_machines_dbtx(
2173 dbtx,
2174 self.client_ctx.map_dyn(states).collect(),
2175 )
2176 .await?;
2177 self.client_ctx
2178 .add_operation_log_entry_dbtx(
2179 dbtx,
2180 operation_id,
2181 MintCommonInit::KIND.as_str(),
2182 MintOperationMeta {
2183 variant: MintOperationMetaVariant::SpendOOB {
2184 requested_amount,
2185 oob_notes: oob_notes.clone(),
2186 no_timeout,
2187 },
2188 amount: oob_notes.total_amount(),
2189 extra_meta,
2190 },
2191 )
2192 .await;
2193 self.client_ctx
2194 .log_event(
2195 dbtx,
2196 OOBNotesSpent {
2197 requested_amount,
2198 spent_amount: oob_notes.total_amount(),
2199 timeout: try_cancel_after,
2200 include_invite,
2201 },
2202 )
2203 .await;
2204
2205 self.client_ctx
2206 .log_event(
2207 dbtx,
2208 SendPaymentEvent {
2209 operation_id,
2210 amount: oob_notes.total_amount(),
2211 oob_notes: encode_prefixed(FEDIMINT_PREFIX, &oob_notes),
2212 },
2213 )
2214 .await;
2215
2216 Ok((operation_id, oob_notes))
2217 })
2218 },
2219 Some(100),
2220 )
2221 .await
2222 .map_err(|e| match e {
2223 AutocommitError::ClosureError { error, .. } => error,
2224 AutocommitError::CommitFailed { last_error, .. } => {
2225 anyhow!("Commit to DB failed: {last_error}")
2226 }
2227 })
2228 }
2229
2230 pub async fn send_oob_notes<M: Serialize + Send>(
2257 &self,
2258 amount: Amount,
2259 extra_meta: M,
2260 ) -> anyhow::Result<OOBNotes> {
2261 let amount = self.cfg.fee_consensus.round_up(amount);
2262
2263 let extra_meta = serde_json::to_value(extra_meta)
2264 .expect("MintClientModule::send_oob_notes extra_meta is serializable");
2265
2266 let oob_notes: Option<OOBNotes> = self
2268 .client_ctx
2269 .module_db()
2270 .autocommit(
2271 |dbtx, _| {
2272 let extra_meta = extra_meta.clone();
2273 Box::pin(async {
2274 self.try_spend_exact_notes_dbtx(
2275 dbtx,
2276 amount,
2277 self.federation_id,
2278 extra_meta,
2279 )
2280 .await
2281 .map(Ok::<OOBNotes, anyhow::Error>)
2282 .transpose()
2283 })
2284 },
2285 Some(100),
2286 )
2287 .await
2288 .expect("Failed to commit dbtx after 100 retries");
2289
2290 if let Some(oob_notes) = oob_notes {
2291 return Ok(oob_notes);
2292 }
2293
2294 self.client_ctx
2296 .global_api()
2297 .session_count()
2298 .await
2299 .context("Cannot reach federation to reissue notes")?;
2300
2301 let operation_id = OperationId::new_random();
2302
2303 let output_bundle = self
2310 .client_ctx
2311 .module_db()
2312 .autocommit(
2313 |dbtx, _| {
2314 Box::pin(async {
2315 Ok::<_, anyhow::Error>(
2316 self.create_exact_output(dbtx, operation_id, amount).await,
2317 )
2318 })
2319 },
2320 Some(100),
2321 )
2322 .await
2323 .expect("Failed to commit output creation after 100 retries");
2324
2325 let explicit_output_count = output_bundle.outputs().len() as u64;
2331
2332 let combined_bundle = ClientOutputBundle::new(
2334 output_bundle.outputs().to_vec(),
2335 output_bundle.sms().to_vec(),
2336 );
2337
2338 let outputs = self.client_ctx.make_client_outputs(combined_bundle);
2339
2340 let em_clone = extra_meta.clone();
2341
2342 let out_point_range = self
2344 .client_ctx
2345 .finalize_and_submit_transaction(
2346 operation_id,
2347 MintCommonInit::KIND.as_str(),
2348 move |change_range: OutPointRange| MintOperationMeta {
2349 variant: MintOperationMetaVariant::Reissuance {
2350 legacy_out_point: None,
2351 txid: Some(change_range.txid()),
2352 out_point_indices: change_range
2353 .into_iter()
2354 .map(|out_point| out_point.out_idx)
2355 .collect(),
2356 },
2357 amount,
2358 extra_meta: em_clone.clone(),
2359 },
2360 TransactionBuilder::new().with_outputs(outputs),
2361 )
2362 .await
2363 .context("Failed to submit reissuance transaction")?;
2364
2365 let txid = out_point_range.txid();
2372 let total_output_count = explicit_output_count + out_point_range.count() as u64;
2373 let all_outputs = OutPointRange::new(txid, IdxRange::from(0..total_output_count));
2374 self.client_ctx
2375 .await_primary_module_outputs(operation_id, all_outputs.into_iter().collect())
2376 .await
2377 .context("Failed to await output finalization")?;
2378
2379 Box::pin(self.send_oob_notes(amount, extra_meta)).await
2381 }
2382
2383 async fn try_spend_exact_notes_dbtx(
2386 &self,
2387 dbtx: &mut DatabaseTransaction<'_>,
2388 amount: Amount,
2389 federation_id: FederationId,
2390 extra_meta: serde_json::Value,
2391 ) -> Option<OOBNotes> {
2392 let selected_notes = Self::select_notes(
2393 dbtx,
2394 &SelectNotesWithExactAmount,
2395 amount,
2396 FeeConsensus::zero(),
2397 )
2398 .await
2399 .ok()?;
2400
2401 for (note_amount, note) in selected_notes.iter_items() {
2403 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, note_amount, note)
2404 .await;
2405 }
2406
2407 let sender = self.balance_update_sender.clone();
2408 dbtx.on_commit(move || sender.send_replace(()));
2409
2410 let operation_id = spendable_notes_to_operation_id(&selected_notes);
2411
2412 let oob_notes = OOBNotes::new(federation_id.to_prefix(), selected_notes);
2413
2414 self.client_ctx
2416 .add_operation_log_entry_dbtx(
2417 dbtx,
2418 operation_id,
2419 MintCommonInit::KIND.as_str(),
2420 MintOperationMeta {
2421 variant: MintOperationMetaVariant::SpendOOB {
2422 requested_amount: amount,
2423 oob_notes: oob_notes.clone(),
2424 no_timeout: true,
2425 },
2426 amount: oob_notes.total_amount(),
2427 extra_meta,
2428 },
2429 )
2430 .await;
2431
2432 self.client_ctx
2433 .log_event(
2434 dbtx,
2435 SendPaymentEvent {
2436 operation_id,
2437 amount: oob_notes.total_amount(),
2438 oob_notes: encode_prefixed(FEDIMINT_PREFIX, &oob_notes),
2439 },
2440 )
2441 .await;
2442
2443 Some(oob_notes)
2444 }
2445
2446 pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> {
2452 let federation_id_prefix = oob_notes.federation_id_prefix();
2453 let notes = oob_notes.notes().clone();
2454
2455 if federation_id_prefix != self.federation_id.to_prefix() {
2456 bail!("Federation ID does not match");
2457 }
2458
2459 let tbs_pks = &self.cfg.tbs_pks;
2460
2461 for (idx, (amt, snote)) in notes.iter_items().enumerate() {
2462 let key = tbs_pks
2463 .get(amt)
2464 .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
2465
2466 let note = snote.note();
2467 if !note.verify(*key) {
2468 bail!("Note {idx} has an invalid federation signature");
2469 }
2470
2471 let expected_nonce = Nonce(snote.spend_key.public_key());
2472 if note.nonce != expected_nonce {
2473 bail!("Note {idx} cannot be spent using the supplied spend key");
2474 }
2475 }
2476
2477 Ok(notes.total_amount())
2478 }
2479
2480 pub async fn check_note_spent(&self, oob_notes: &OOBNotes) -> anyhow::Result<bool> {
2486 use crate::api::MintFederationApi;
2487
2488 let api_client = self.client_ctx.module_api();
2489 let any_spent = try_join_all(oob_notes.notes().iter().flat_map(|(_, notes)| {
2490 notes
2491 .iter()
2492 .map(|note| api_client.check_note_spent(note.nonce()))
2493 }))
2494 .await?
2495 .into_iter()
2496 .any(|spent| spent);
2497
2498 Ok(any_spent)
2499 }
2500
2501 pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
2506 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2507 dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
2508 .await;
2509 if let Err(e) = dbtx.commit_tx_result().await {
2510 warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
2511 }
2512 }
2513
2514 pub async fn subscribe_spend_notes(
2517 &self,
2518 operation_id: OperationId,
2519 ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
2520 let operation = self.mint_operation(operation_id).await?;
2521 let MintOperationMetaVariant::SpendOOB { no_timeout, .. } =
2522 operation.meta::<MintOperationMeta>().variant
2523 else {
2524 bail!("Operation is not a out-of-band spend");
2525 };
2526
2527 let client_ctx = self.client_ctx.clone();
2528
2529 Ok(self
2530 .client_ctx
2531 .outcome_or_updates(operation, operation_id, move || {
2532 stream! {
2533 yield SpendOOBState::Created;
2534
2535 if no_timeout {
2536 yield SpendOOBState::Success;
2537 return;
2538 }
2539
2540 let self_ref = client_ctx.self_ref();
2541
2542 let refund = self_ref
2543 .await_spend_oob_refund(operation_id)
2544 .await;
2545
2546 if refund.user_triggered {
2547 yield SpendOOBState::UserCanceledProcessing;
2548 }
2549
2550 let mut success = true;
2551
2552 for txid in refund.transaction_ids {
2553 debug!(
2554 target: LOG_CLIENT_MODULE_MINT,
2555 %txid,
2556 operation_id=%operation_id.fmt_short(),
2557 "Waiting for oob refund txid"
2558 );
2559 if client_ctx
2560 .transaction_updates(operation_id)
2561 .await
2562 .await_tx_accepted(txid)
2563 .await.is_err() {
2564 success = false;
2565 }
2566 }
2567
2568 debug!(
2569 target: LOG_CLIENT_MODULE_MINT,
2570 operation_id=%operation_id.fmt_short(),
2571 %success,
2572 "Done waiting for all refund oob txids"
2573 );
2574
2575 match (refund.user_triggered, success) {
2576 (true, true) => {
2577 yield SpendOOBState::UserCanceledSuccess;
2578 },
2579 (true, false) => {
2580 yield SpendOOBState::UserCanceledFailure;
2581 },
2582 (false, true) => {
2583 yield SpendOOBState::Refunded;
2584 },
2585 (false, false) => {
2586 yield SpendOOBState::Success;
2587 }
2588 }
2589 }
2590 }))
2591 }
2592
2593 async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
2594 let operation = self.client_ctx.get_operation(operation_id).await?;
2595
2596 if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
2597 bail!("Operation is not a mint operation");
2598 }
2599
2600 Ok(operation)
2601 }
2602
2603 async fn delete_spendable_note(
2604 client_ctx: &ClientContext<MintClientModule>,
2605 dbtx: &mut DatabaseTransaction<'_>,
2606 amount: Amount,
2607 note: &SpendableNote,
2608 ) {
2609 client_ctx
2610 .log_event(
2611 dbtx,
2612 NoteSpent {
2613 nonce: note.nonce(),
2614 },
2615 )
2616 .await;
2617 dbtx.remove_entry(&NoteKey {
2618 amount,
2619 nonce: note.nonce(),
2620 })
2621 .await
2622 .expect("Must deleted existing spendable note");
2623 }
2624
2625 pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> {
2626 let db = self.client_ctx.module_db().clone();
2627
2628 Ok(db
2629 .autocommit(
2630 |dbtx, _| {
2631 Box::pin(async {
2632 Ok::<DerivableSecret, anyhow::Error>(
2633 self.new_note_secret(amount, dbtx).await,
2634 )
2635 })
2636 },
2637 None,
2638 )
2639 .await?)
2640 }
2641
2642 pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
2645 self.client_ctx
2646 .module_db()
2647 .begin_transaction_nc()
2648 .await
2649 .get_value(&ReusedNoteIndices)
2650 .await
2651 .unwrap_or_default()
2652 .into_iter()
2653 .map(|(amount, note_idx)| {
2654 let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
2655 let (request, blind_nonce) =
2656 NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
2657 (amount, request, blind_nonce)
2658 })
2659 .collect()
2660 }
2661}
2662
2663pub fn spendable_notes_to_operation_id(
2664 spendable_selected_notes: &TieredMulti<SpendableNote>,
2665) -> OperationId {
2666 OperationId(
2667 spendable_selected_notes
2668 .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
2669 .to_byte_array(),
2670 )
2671}
2672
2673#[derive(Debug, Serialize, Deserialize, Clone)]
2674pub struct SpendOOBRefund {
2675 pub user_triggered: bool,
2676 pub transaction_ids: Vec<TransactionId>,
2679}
2680
2681#[apply(async_trait_maybe_send!)]
2684pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
2685 async fn select_notes(
2688 &self,
2689 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2691 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2692 requested_amount: Amount,
2693 fee_consensus: FeeConsensus,
2694 ) -> anyhow::Result<TieredMulti<Note>>;
2695}
2696
2697pub struct SelectNotesWithAtleastAmount;
2703
2704#[apply(async_trait_maybe_send!)]
2705impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
2706 async fn select_notes(
2707 &self,
2708 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2709 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2710 requested_amount: Amount,
2711 fee_consensus: FeeConsensus,
2712 ) -> anyhow::Result<TieredMulti<Note>> {
2713 Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
2714 }
2715}
2716
2717pub struct SelectNotesWithExactAmount;
2721
2722#[apply(async_trait_maybe_send!)]
2723impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
2724 async fn select_notes(
2725 &self,
2726 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2727 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2728 requested_amount: Amount,
2729 fee_consensus: FeeConsensus,
2730 ) -> anyhow::Result<TieredMulti<Note>> {
2731 let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
2732
2733 if notes.total_amount() != requested_amount {
2734 bail!(
2735 "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}",
2736 requested_amount,
2737 notes.total_amount()
2738 );
2739 }
2740
2741 Ok(notes)
2742 }
2743}
2744
2745async fn select_notes_from_stream<Note>(
2751 stream: impl futures::Stream<Item = (Amount, Note)>,
2752 requested_amount: Amount,
2753 fee_consensus: FeeConsensus,
2754) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
2755 if requested_amount == Amount::ZERO {
2756 return Ok(TieredMulti::default());
2757 }
2758 let mut stream = Box::pin(stream);
2759 let mut selected = vec![];
2760 let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
2765 let mut pending_amount = requested_amount;
2766 let mut previous_amount: Option<Amount> = None; loop {
2768 if let Some((note_amount, note)) = stream.next().await {
2769 assert!(
2770 previous_amount.is_none_or(|previous| previous >= note_amount),
2771 "notes are not sorted in descending order"
2772 );
2773 previous_amount = Some(note_amount);
2774
2775 if note_amount <= fee_consensus.fee(note_amount) {
2776 continue;
2777 }
2778
2779 match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2780 Ordering::Less => {
2781 pending_amount += fee_consensus.fee(note_amount);
2783 pending_amount -= note_amount;
2784 selected.push((note_amount, note));
2785 }
2786 Ordering::Greater => {
2787 last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2791 }
2792 Ordering::Equal => {
2793 selected.push((note_amount, note));
2795
2796 let notes: TieredMulti<Note> = selected.into_iter().collect();
2797
2798 assert!(
2799 notes.total_amount().msats
2800 >= requested_amount.msats
2801 + notes
2802 .iter()
2803 .map(|note| fee_consensus.fee(note.0))
2804 .sum::<Amount>()
2805 .msats
2806 );
2807
2808 return Ok(notes);
2809 }
2810 }
2811 } else {
2812 assert!(pending_amount > Amount::ZERO);
2813 if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2814 selected.truncate(checkpoint);
2817 selected.push((big_note_amount, big_note));
2819
2820 let notes: TieredMulti<Note> = selected.into_iter().collect();
2821
2822 assert!(
2823 notes.total_amount().msats
2824 >= requested_amount.msats
2825 + notes
2826 .iter()
2827 .map(|note| fee_consensus.fee(note.0))
2828 .sum::<Amount>()
2829 .msats
2830 );
2831
2832 return Ok(notes);
2834 }
2835
2836 let total_amount = requested_amount.saturating_sub(pending_amount);
2837 return Err(InsufficientBalanceError {
2839 requested_amount,
2840 total_amount,
2841 });
2842 }
2843 }
2844}
2845
2846#[derive(Debug, Clone, Error)]
2847pub struct InsufficientBalanceError {
2848 pub requested_amount: Amount,
2849 pub total_amount: Amount,
2850}
2851
2852impl std::fmt::Display for InsufficientBalanceError {
2853 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2854 write!(
2855 f,
2856 "Insufficient balance: requested {} but only {} available",
2857 self.requested_amount, self.total_amount
2858 )
2859 }
2860}
2861
2862#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2864enum MintRestoreStates {
2865 #[encodable_default]
2866 Default { variant: u64, bytes: Vec<u8> },
2867}
2868
2869#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2871pub struct MintRestoreStateMachine {
2872 operation_id: OperationId,
2873 state: MintRestoreStates,
2874}
2875
2876#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2877pub enum MintClientStateMachines {
2878 Output(MintOutputStateMachine),
2879 Input(MintInputStateMachine),
2880 OOB(MintOOBStateMachine),
2881 Restore(MintRestoreStateMachine),
2883}
2884
2885impl IntoDynInstance for MintClientStateMachines {
2886 type DynType = DynState;
2887
2888 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2889 DynState::from_typed(instance_id, self)
2890 }
2891}
2892
2893impl State for MintClientStateMachines {
2894 type ModuleContext = MintClientContext;
2895
2896 fn transitions(
2897 &self,
2898 context: &Self::ModuleContext,
2899 global_context: &DynGlobalClientContext,
2900 ) -> Vec<StateTransition<Self>> {
2901 match self {
2902 MintClientStateMachines::Output(issuance_state) => {
2903 sm_enum_variant_translation!(
2904 issuance_state.transitions(context, global_context),
2905 MintClientStateMachines::Output
2906 )
2907 }
2908 MintClientStateMachines::Input(redemption_state) => {
2909 sm_enum_variant_translation!(
2910 redemption_state.transitions(context, global_context),
2911 MintClientStateMachines::Input
2912 )
2913 }
2914 MintClientStateMachines::OOB(oob_state) => {
2915 sm_enum_variant_translation!(
2916 oob_state.transitions(context, global_context),
2917 MintClientStateMachines::OOB
2918 )
2919 }
2920 MintClientStateMachines::Restore(_) => {
2921 sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
2922 }
2923 }
2924 }
2925
2926 fn operation_id(&self) -> OperationId {
2927 match self {
2928 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
2929 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
2930 MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
2931 MintClientStateMachines::Restore(r) => r.operation_id,
2932 }
2933 }
2934
2935 fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
2936 match self {
2937 MintClientStateMachines::Output(s) => s.fmt_visualization(f, indent),
2938 MintClientStateMachines::Input(s) => s.fmt_visualization(f, indent),
2939 MintClientStateMachines::OOB(s) => s.fmt_visualization(f, indent),
2940 MintClientStateMachines::Restore(_) => write!(f, "{indent}{self:?}"),
2941 }
2942 }
2943}
2944
2945#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
2948pub struct SpendableNote {
2949 pub signature: tbs::Signature,
2950 pub spend_key: Keypair,
2951}
2952
2953impl fmt::Debug for SpendableNote {
2954 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2955 f.debug_struct("SpendableNote")
2956 .field("nonce", &self.nonce())
2957 .field("signature", &self.signature)
2958 .field("spend_key", &self.spend_key)
2959 .finish()
2960 }
2961}
2962impl fmt::Display for SpendableNote {
2963 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2964 write!(f, "{}", self.nonce().fmt_short())
2965 }
2966}
2967
2968impl SpendableNote {
2969 pub fn nonce(&self) -> Nonce {
2970 Nonce(self.spend_key.public_key())
2971 }
2972
2973 fn note(&self) -> Note {
2974 Note {
2975 nonce: self.nonce(),
2976 signature: self.signature,
2977 }
2978 }
2979
2980 pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
2981 SpendableNoteUndecoded {
2982 signature: self
2983 .signature
2984 .consensus_encode_to_vec()
2985 .try_into()
2986 .expect("Encoded size always correct"),
2987 spend_key: self.spend_key,
2988 }
2989 }
2990}
2991
2992#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
3004pub struct SpendableNoteUndecoded {
3005 #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
3008 pub signature: [u8; 48],
3009 pub spend_key: Keypair,
3010}
3011
3012impl fmt::Display for SpendableNoteUndecoded {
3013 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3014 write!(f, "{}", self.nonce().fmt_short())
3015 }
3016}
3017
3018impl fmt::Debug for SpendableNoteUndecoded {
3019 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
3020 f.debug_struct("SpendableNote")
3021 .field("nonce", &self.nonce())
3022 .field("signature", &"[raw]")
3023 .field("spend_key", &self.spend_key)
3024 .finish()
3025 }
3026}
3027
3028impl SpendableNoteUndecoded {
3029 fn nonce(&self) -> Nonce {
3030 Nonce(self.spend_key.public_key())
3031 }
3032
3033 pub fn decode(self) -> anyhow::Result<SpendableNote> {
3034 Ok(SpendableNote {
3035 signature: Decodable::consensus_decode_partial_from_finite_reader(
3036 &mut self.signature.as_slice(),
3037 &ModuleRegistry::default(),
3038 )?,
3039 spend_key: self.spend_key,
3040 })
3041 }
3042}
3043
3044#[derive(
3050 Copy,
3051 Clone,
3052 Debug,
3053 Serialize,
3054 Deserialize,
3055 PartialEq,
3056 Eq,
3057 Encodable,
3058 Decodable,
3059 Default,
3060 PartialOrd,
3061 Ord,
3062)]
3063pub struct NoteIndex(u64);
3064
3065impl NoteIndex {
3066 pub fn next(self) -> Self {
3067 Self(self.0 + 1)
3068 }
3069
3070 fn prev(self) -> Option<Self> {
3071 self.0.checked_sub(0).map(Self)
3072 }
3073
3074 pub fn as_u64(self) -> u64 {
3075 self.0
3076 }
3077
3078 #[allow(unused)]
3082 pub fn from_u64(v: u64) -> Self {
3083 Self(v)
3084 }
3085
3086 pub fn advance(&mut self) {
3087 *self = self.next();
3088 }
3089}
3090
3091impl std::fmt::Display for NoteIndex {
3092 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3093 self.0.fmt(f)
3094 }
3095}
3096
3097struct OOBSpendTag;
3098
3099impl sha256t::Tag for OOBSpendTag {
3100 fn engine() -> sha256::HashEngine {
3101 let mut engine = sha256::HashEngine::default();
3102 engine.input(b"oob-spend");
3103 engine
3104 }
3105}
3106
3107struct OOBReissueTag;
3108
3109impl sha256t::Tag for OOBReissueTag {
3110 fn engine() -> sha256::HashEngine {
3111 let mut engine = sha256::HashEngine::default();
3112 engine.input(b"oob-reissue");
3113 engine
3114 }
3115}
3116
3117pub fn represent_amount<K>(
3123 amount: Amount,
3124 current_denominations: &TieredCounts,
3125 tiers: &Tiered<K>,
3126 denomination_sets: u16,
3127 fee_consensus: &FeeConsensus,
3128) -> TieredCounts {
3129 let mut remaining_amount = amount;
3130 let mut denominations = TieredCounts::default();
3131
3132 for tier in tiers.tiers() {
3134 let notes = current_denominations.get(*tier);
3135 let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
3136 let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
3137
3138 let add_notes = min(possible_notes, missing_notes);
3139 denominations.inc(*tier, add_notes as usize);
3140 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
3141 }
3142
3143 for tier in tiers.tiers().rev() {
3145 let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
3146 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
3147 denominations.inc(*tier, res as usize);
3148 }
3149
3150 let represented: u64 = denominations
3151 .iter()
3152 .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
3153 .sum();
3154
3155 assert!(represented <= amount.msats);
3156 assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
3157
3158 denominations
3159}
3160
3161pub(crate) fn create_bundle_for_inputs(
3162 inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
3163 operation_id: OperationId,
3164) -> ClientInputBundle<MintInput, MintClientStateMachines> {
3165 let mut inputs = Vec::new();
3166 let mut input_states = Vec::new();
3167
3168 for (input, spendable_note) in inputs_and_notes {
3169 input_states.push((input.amounts.clone(), spendable_note));
3170 inputs.push(input);
3171 }
3172
3173 let input_sm = Arc::new(move |out_point_range: OutPointRange| {
3174 debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
3175
3176 vec![MintClientStateMachines::Input(MintInputStateMachine {
3177 common: MintInputCommon {
3178 operation_id,
3179 out_point_range,
3180 },
3181 state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
3182 notes: input_states
3183 .iter()
3184 .map(|(amounts, note)| (amounts.expect_only_bitcoin(), *note))
3185 .collect(),
3186 }),
3187 })]
3188 });
3189
3190 ClientInputBundle::new(
3191 inputs,
3192 vec![ClientInputSM {
3193 state_machines: input_sm,
3194 }],
3195 )
3196}
3197
3198#[cfg(test)]
3199mod tests {
3200 use std::fmt::Display;
3201 use std::str::FromStr;
3202
3203 use bitcoin_hashes::Hash;
3204 use fedimint_core::base32::FEDIMINT_PREFIX;
3205 use fedimint_core::config::FederationId;
3206 use fedimint_core::encoding::Decodable;
3207 use fedimint_core::invite_code::InviteCode;
3208 use fedimint_core::module::registry::ModuleRegistry;
3209 use fedimint_core::{
3210 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
3211 };
3212 use fedimint_mint_common::config::FeeConsensus;
3213 use itertools::Itertools;
3214 use serde_json::json;
3215
3216 use crate::{
3217 MintOperationMetaVariant, OOBNotes, OOBNotesPart, SpendableNote, SpendableNoteUndecoded,
3218 represent_amount, select_notes_from_stream,
3219 };
3220
3221 #[test]
3222 fn represent_amount_targets_denomination_sets() {
3223 fn tiers(tiers: Vec<u64>) -> Tiered<()> {
3224 tiers
3225 .into_iter()
3226 .map(|tier| (Amount::from_sats(tier), ()))
3227 .collect()
3228 }
3229
3230 fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
3231 TieredCounts::from_iter(denominations)
3232 }
3233
3234 let starting = notes(vec![
3235 (Amount::from_sats(1), 1),
3236 (Amount::from_sats(2), 3),
3237 (Amount::from_sats(3), 2),
3238 ])
3239 .summary();
3240 let tiers = tiers(vec![1, 2, 3, 4]);
3241
3242 assert_eq!(
3244 represent_amount(
3245 Amount::from_sats(6),
3246 &starting,
3247 &tiers,
3248 3,
3249 &FeeConsensus::zero()
3250 ),
3251 denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
3252 );
3253
3254 assert_eq!(
3256 represent_amount(
3257 Amount::from_sats(6),
3258 &starting,
3259 &tiers,
3260 2,
3261 &FeeConsensus::zero()
3262 ),
3263 denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
3264 );
3265 }
3266
3267 #[test_log::test(tokio::test)]
3268 async fn select_notes_avg_test() {
3269 let max_amount = Amount::from_sats(1_000_000);
3270 let tiers = Tiered::gen_denominations(2, max_amount);
3271 let tiered = represent_amount::<()>(
3272 max_amount,
3273 &TieredCounts::default(),
3274 &tiers,
3275 3,
3276 &FeeConsensus::zero(),
3277 );
3278
3279 let mut total_notes = 0;
3280 for multiplier in 1..100 {
3281 let stream = reverse_sorted_note_stream(tiered.iter().collect());
3282 let select = select_notes_from_stream(
3283 stream,
3284 Amount::from_sats(multiplier * 1000),
3285 FeeConsensus::zero(),
3286 )
3287 .await;
3288 total_notes += select.unwrap().into_iter_items().count();
3289 }
3290 assert_eq!(total_notes / 100, 10);
3291 }
3292
3293 #[test_log::test(tokio::test)]
3294 async fn select_notes_returns_exact_amount_with_minimum_notes() {
3295 let f = || {
3296 reverse_sorted_note_stream(vec![
3297 (Amount::from_sats(1), 10),
3298 (Amount::from_sats(5), 10),
3299 (Amount::from_sats(20), 10),
3300 ])
3301 };
3302 assert_eq!(
3303 select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
3304 .await
3305 .unwrap(),
3306 notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
3307 );
3308 assert_eq!(
3309 select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
3310 .await
3311 .unwrap(),
3312 notes(vec![(Amount::from_sats(20), 1)])
3313 );
3314 }
3315
3316 #[test_log::test(tokio::test)]
3317 async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
3318 let stream = reverse_sorted_note_stream(vec![
3319 (Amount::from_sats(1), 1),
3320 (Amount::from_sats(5), 5),
3321 (Amount::from_sats(20), 5),
3322 ]);
3323 assert_eq!(
3324 select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
3325 .await
3326 .unwrap(),
3327 notes(vec![(Amount::from_sats(5), 2)])
3328 );
3329 }
3330
3331 #[test_log::test(tokio::test)]
3332 async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
3333 let stream = reverse_sorted_note_stream(vec![
3334 (Amount::from_sats(1), 3),
3335 (Amount::from_sats(5), 3),
3336 (Amount::from_sats(20), 2),
3337 ]);
3338 assert_eq!(
3339 select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
3340 .await
3341 .unwrap(),
3342 notes(vec![(Amount::from_sats(20), 2)])
3343 );
3344 }
3345
3346 #[test_log::test(tokio::test)]
3347 async fn select_notes_returns_error_if_amount_is_too_large() {
3348 let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
3349 let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
3350 .await
3351 .unwrap_err();
3352 assert_eq!(error.total_amount, Amount::from_sats(10));
3353 }
3354
3355 fn reverse_sorted_note_stream(
3356 notes: Vec<(Amount, usize)>,
3357 ) -> impl futures::Stream<Item = (Amount, String)> {
3358 futures::stream::iter(
3359 notes
3360 .into_iter()
3361 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
3363 .sorted()
3364 .rev(),
3365 )
3366 }
3367
3368 fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
3369 notes
3370 .into_iter()
3371 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
3372 .collect()
3373 }
3374
3375 #[test]
3376 fn decoding_empty_oob_notes_fails() {
3377 let empty_oob_notes =
3378 OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
3379 let oob_notes_string = empty_oob_notes.to_string();
3380
3381 let res = oob_notes_string.parse::<OOBNotes>();
3382
3383 assert!(res.is_err(), "An empty OOB notes string should not parse");
3384 }
3385
3386 fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
3387 where
3388 T: FromStr + Display + crate::Encodable + crate::Decodable,
3389 <T as FromStr>::Err: std::fmt::Debug,
3390 F: Fn(T),
3391 {
3392 let data_parsed = data.to_string().parse().expect("Deserialization failed");
3393
3394 assertions(data_parsed);
3395
3396 let data_parsed = crate::base32::encode_prefixed(FEDIMINT_PREFIX, &data)
3397 .parse()
3398 .expect("Deserialization failed");
3399
3400 assertions(data_parsed);
3401
3402 assertions(data);
3403 }
3404
3405 #[test]
3406 fn notes_encode_decode() {
3407 let federation_id_1 =
3408 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
3409 let federation_id_prefix_1 = federation_id_1.to_prefix();
3410 let federation_id_2 =
3411 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
3412 let federation_id_prefix_2 = federation_id_2.to_prefix();
3413
3414 let notes = vec![(
3415 Amount::from_sats(1),
3416 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
3417 )]
3418 .into_iter()
3419 .collect::<TieredMulti<_>>();
3420
3421 let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
3423 test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
3424 assert_eq!(oob_notes.notes(), ¬es);
3425 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3426 assert_eq!(oob_notes.federation_invite(), None);
3427 });
3428
3429 let invite = InviteCode::new(
3431 "wss://foo.bar".parse().unwrap(),
3432 PeerId::from(0),
3433 federation_id_1,
3434 None,
3435 );
3436 let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
3437 test_roundtrip_serialize_str(notes_invite, |oob_notes| {
3438 assert_eq!(oob_notes.notes(), ¬es);
3439 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3440 assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
3441 });
3442
3443 let notes_no_prefix = OOBNotes(vec![
3446 OOBNotesPart::Notes(notes.clone()),
3447 OOBNotesPart::Invite {
3448 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
3449 federation_id: federation_id_1,
3450 },
3451 ]);
3452 test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
3453 assert_eq!(oob_notes.notes(), ¬es);
3454 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
3455 });
3456
3457 let notes_inconsistent = OOBNotes(vec![
3459 OOBNotesPart::Notes(notes),
3460 OOBNotesPart::Invite {
3461 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
3462 federation_id: federation_id_1,
3463 },
3464 OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
3465 ]);
3466 let notes_inconsistent_str = notes_inconsistent.to_string();
3467 assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
3468 }
3469
3470 #[test]
3471 fn spendable_note_undecoded_sanity() {
3472 #[allow(clippy::single_element_loop)]
3474 for note_hex in [
3475 "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
3476 ] {
3477 let note =
3478 SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
3479 let note_undecoded =
3480 SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
3481 .unwrap()
3482 .decode()
3483 .unwrap();
3484 assert_eq!(note, note_undecoded,);
3485 assert_eq!(
3486 serde_json::to_string(¬e).unwrap(),
3487 serde_json::to_string(¬e_undecoded).unwrap(),
3488 );
3489 }
3490 }
3491
3492 #[test]
3493 fn reissuance_meta_compatibility_02_03() {
3494 let dummy_outpoint = OutPoint {
3495 txid: TransactionId::all_zeros(),
3496 out_idx: 0,
3497 };
3498
3499 let old_meta_json = json!({
3500 "reissuance": {
3501 "out_point": dummy_outpoint
3502 }
3503 });
3504
3505 let old_meta: MintOperationMetaVariant =
3506 serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
3507 assert_eq!(
3508 old_meta,
3509 MintOperationMetaVariant::Reissuance {
3510 legacy_out_point: Some(dummy_outpoint),
3511 txid: None,
3512 out_point_indices: vec![],
3513 }
3514 );
3515
3516 let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
3517 legacy_out_point: None,
3518 txid: Some(dummy_outpoint.txid),
3519 out_point_indices: vec![0],
3520 })
3521 .expect("serializing always works");
3522 assert_eq!(
3523 new_meta_json,
3524 json!({
3525 "reissuance": {
3526 "txid": dummy_outpoint.txid,
3527 "out_point_indices": [dummy_outpoint.out_idx],
3528 }
3529 })
3530 );
3531 }
3532
3533 #[test]
3534 fn spend_oob_meta_no_timeout_defaults_to_false() {
3535 let notes = vec![(
3536 Amount::from_sats(1),
3537 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
3538 )]
3539 .into_iter()
3540 .collect::<TieredMulti<_>>();
3541 let oob_notes = OOBNotes::new(FederationId::dummy().to_prefix(), notes);
3542 let mut old_meta_json = serde_json::to_value(MintOperationMetaVariant::SpendOOB {
3543 requested_amount: Amount::from_sats(42),
3544 oob_notes: oob_notes.clone(),
3545 no_timeout: false,
3546 })
3547 .expect("serializing always works");
3548 old_meta_json
3549 .get_mut("spend_o_o_b")
3550 .expect("spend OOB variant should serialize as spend_o_o_b")
3551 .as_object_mut()
3552 .expect("spend OOB variant should serialize to an object")
3553 .remove("no_timeout");
3554 assert_eq!(
3555 old_meta_json,
3556 json!({
3557 "spend_o_o_b": {
3558 "requested_amount": Amount::from_sats(42),
3559 "oob_notes": oob_notes.clone(),
3560 }
3561 })
3562 );
3563
3564 let old_meta: MintOperationMetaVariant =
3565 serde_json::from_value(old_meta_json).expect("parsing old spend OOB meta failed");
3566 assert_eq!(
3567 old_meta,
3568 MintOperationMetaVariant::SpendOOB {
3569 requested_amount: Amount::from_sats(42),
3570 oob_notes,
3571 no_timeout: false,
3572 }
3573 );
3574 }
3575}