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