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