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