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;
37use std::time::Duration;
38
39use anyhow::{Context as _, anyhow, bail, ensure};
40use async_stream::{stream, try_stream};
41use backup::recovery::MintRecovery;
42use base64::Engine as _;
43use bitcoin_hashes::{Hash, HashEngine as BitcoinHashEngine, sha256, sha256t};
44use client_db::{
45 DbKeyPrefix, NoteKeyPrefix, RecoveryFinalizedKey, ReusedNoteIndices, migrate_state_to_v2,
46 migrate_to_v1,
47};
48use event::{NoteSpent, OOBNotesReissued, OOBNotesSpent, ReceivePaymentEvent, SendPaymentEvent};
49use fedimint_client_module::db::{ClientModuleMigrationFn, migrate_state};
50use fedimint_client_module::module::init::{
51 ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs,
52};
53use fedimint_client_module::module::{
54 ClientContext, ClientModule, IClientModule, OutPointRange, PrimaryModulePriority,
55 PrimaryModuleSupport,
56};
57use fedimint_client_module::oplog::{OperationLogEntry, UpdateStreamOrOutcome};
58use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
59use fedimint_client_module::transaction::{
60 ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
61 ClientOutputSM, TransactionBuilder,
62};
63use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
64use fedimint_core::base32::FEDIMINT_PREFIX;
65use fedimint_core::config::{FederationId, FederationIdPrefix};
66use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
67use fedimint_core::db::{
68 AutocommitError, Database, DatabaseTransaction, DatabaseVersion,
69 IDatabaseTransactionOpsCoreTyped,
70};
71use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
72use fedimint_core::invite_code::InviteCode;
73use fedimint_core::module::registry::{ModuleDecoderRegistry, ModuleRegistry};
74use fedimint_core::module::{
75 AmountUnit, Amounts, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
76};
77use fedimint_core::secp256k1::{All, Keypair, Secp256k1};
78use fedimint_core::util::{BoxFuture, BoxStream, NextOrPending, SafeUrl};
79use fedimint_core::{
80 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId, apply,
81 async_trait_maybe_send, base32, push_db_pair_items,
82};
83use fedimint_derive_secret::{ChildId, DerivableSecret};
84use fedimint_logging::LOG_CLIENT_MODULE_MINT;
85pub use fedimint_mint_common as common;
86use fedimint_mint_common::config::{FeeConsensus, MintClientConfig};
87pub use fedimint_mint_common::*;
88use futures::future::try_join_all;
89use futures::{StreamExt, pin_mut};
90use hex::ToHex;
91use input::MintInputStateCreatedBundle;
92use itertools::Itertools as _;
93use oob::MintOOBStatesCreatedMulti;
94use output::MintOutputStatesCreatedMulti;
95use serde::{Deserialize, Serialize};
96use strum::IntoEnumIterator;
97use tbs::AggregatePublicKey;
98use thiserror::Error;
99use tracing::{debug, warn};
100
101use crate::backup::EcashBackup;
102use crate::client_db::{
103 CancelledOOBSpendKey, CancelledOOBSpendKeyPrefix, NextECashNoteIndexKey,
104 NextECashNoteIndexKeyPrefix, NoteKey,
105};
106use crate::input::{MintInputCommon, MintInputStateMachine, MintInputStates};
107use crate::oob::{MintOOBStateMachine, MintOOBStates};
108use crate::output::{
109 MintOutputCommon, MintOutputStateMachine, MintOutputStates, NoteIssuanceRequest,
110};
111
112const MINT_E_CASH_TYPE_CHILD_ID: ChildId = ChildId(0);
113
114#[derive(Clone, Debug, Encodable, PartialEq, Eq)]
122pub struct OOBNotes(Vec<OOBNotesPart>);
123
124#[derive(Clone, Debug, Decodable, Encodable, PartialEq, Eq)]
127enum OOBNotesPart {
128 Notes(TieredMulti<SpendableNote>),
129 FederationIdPrefix(FederationIdPrefix),
130 Invite {
134 peer_apis: Vec<(PeerId, SafeUrl)>,
136 federation_id: FederationId,
137 },
138 ApiSecret(String),
139 #[encodable_default]
140 Default {
141 variant: u64,
142 bytes: Vec<u8>,
143 },
144}
145
146impl OOBNotes {
147 pub fn new(
148 federation_id_prefix: FederationIdPrefix,
149 notes: TieredMulti<SpendableNote>,
150 ) -> Self {
151 Self(vec![
152 OOBNotesPart::FederationIdPrefix(federation_id_prefix),
153 OOBNotesPart::Notes(notes),
154 ])
155 }
156
157 pub fn new_with_invite(notes: TieredMulti<SpendableNote>, invite: &InviteCode) -> Self {
158 let mut data = vec![
159 OOBNotesPart::FederationIdPrefix(invite.federation_id().to_prefix()),
162 OOBNotesPart::Notes(notes),
163 OOBNotesPart::Invite {
164 peer_apis: vec![(invite.peer(), invite.url())],
165 federation_id: invite.federation_id(),
166 },
167 ];
168 if let Some(api_secret) = invite.api_secret() {
169 data.push(OOBNotesPart::ApiSecret(api_secret));
170 }
171 Self(data)
172 }
173
174 pub fn federation_id_prefix(&self) -> FederationIdPrefix {
175 self.0
176 .iter()
177 .find_map(|data| match data {
178 OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
179 OOBNotesPart::Invite { federation_id, .. } => Some(federation_id.to_prefix()),
180 _ => None,
181 })
182 .expect("Invariant violated: OOBNotes does not contain a FederationIdPrefix")
183 }
184
185 pub fn notes(&self) -> &TieredMulti<SpendableNote> {
186 self.0
187 .iter()
188 .find_map(|data| match data {
189 OOBNotesPart::Notes(notes) => Some(notes),
190 _ => None,
191 })
192 .expect("Invariant violated: OOBNotes does not contain any notes")
193 }
194
195 pub fn notes_json(&self) -> Result<serde_json::Value, serde_json::Error> {
196 let mut notes_map = serde_json::Map::new();
197 for notes in &self.0 {
198 match notes {
199 OOBNotesPart::Notes(notes) => {
200 let notes_json = serde_json::to_value(notes)?;
201 notes_map.insert("notes".to_string(), notes_json);
202 }
203 OOBNotesPart::FederationIdPrefix(prefix) => {
204 notes_map.insert(
205 "federation_id_prefix".to_string(),
206 serde_json::to_value(prefix.to_string())?,
207 );
208 }
209 OOBNotesPart::Invite {
210 peer_apis,
211 federation_id,
212 } => {
213 let (peer_id, api) = peer_apis
214 .first()
215 .cloned()
216 .expect("Decoding makes sure peer_apis isn't empty");
217 notes_map.insert(
218 "invite".to_string(),
219 serde_json::to_value(InviteCode::new(
220 api,
221 peer_id,
222 *federation_id,
223 self.api_secret(),
224 ))?,
225 );
226 }
227 OOBNotesPart::ApiSecret(_) => { }
228 OOBNotesPart::Default { variant, bytes } => {
229 notes_map.insert(
230 format!("default_{variant}"),
231 serde_json::to_value(bytes.encode_hex::<String>())?,
232 );
233 }
234 }
235 }
236 Ok(serde_json::Value::Object(notes_map))
237 }
238
239 pub fn federation_invite(&self) -> Option<InviteCode> {
240 self.0.iter().find_map(|data| {
241 let OOBNotesPart::Invite {
242 peer_apis,
243 federation_id,
244 } = data
245 else {
246 return None;
247 };
248 let (peer_id, api) = peer_apis
249 .first()
250 .cloned()
251 .expect("Decoding makes sure peer_apis isn't empty");
252 Some(InviteCode::new(
253 api,
254 peer_id,
255 *federation_id,
256 self.api_secret(),
257 ))
258 })
259 }
260
261 fn api_secret(&self) -> Option<String> {
262 self.0.iter().find_map(|data| {
263 let OOBNotesPart::ApiSecret(api_secret) = data else {
264 return None;
265 };
266 Some(api_secret.clone())
267 })
268 }
269}
270
271impl Decodable for OOBNotes {
272 fn consensus_decode_partial<R: Read>(
273 r: &mut R,
274 _modules: &ModuleDecoderRegistry,
275 ) -> Result<Self, DecodeError> {
276 let inner =
277 Vec::<OOBNotesPart>::consensus_decode_partial(r, &ModuleDecoderRegistry::default())?;
278
279 if !inner
281 .iter()
282 .any(|data| matches!(data, OOBNotesPart::Notes(_)))
283 {
284 return Err(DecodeError::from_str(
285 "No e-cash notes were found in OOBNotes data",
286 ));
287 }
288
289 let maybe_federation_id_prefix = inner.iter().find_map(|data| match data {
290 OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
291 _ => None,
292 });
293
294 let maybe_invite = inner.iter().find_map(|data| match data {
295 OOBNotesPart::Invite {
296 federation_id,
297 peer_apis,
298 } => Some((federation_id, peer_apis)),
299 _ => None,
300 });
301
302 match (maybe_federation_id_prefix, maybe_invite) {
303 (Some(p), Some((ip, _))) => {
304 if p != ip.to_prefix() {
305 return Err(DecodeError::from_str(
306 "Inconsistent Federation ID provided in OOBNotes data",
307 ));
308 }
309 }
310 (None, None) => {
311 return Err(DecodeError::from_str(
312 "No Federation ID provided in OOBNotes data",
313 ));
314 }
315 _ => {}
316 }
317
318 if let Some((_, invite)) = maybe_invite
319 && invite.is_empty()
320 {
321 return Err(DecodeError::from_str("Invite didn't contain API endpoints"));
322 }
323
324 Ok(OOBNotes(inner))
325 }
326}
327
328const BASE64_URL_SAFE: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new(
329 &base64::alphabet::URL_SAFE,
330 base64::engine::general_purpose::PAD,
331);
332
333impl FromStr for OOBNotes {
334 type Err = anyhow::Error;
335
336 fn from_str(s: &str) -> Result<Self, Self::Err> {
338 let s: String = s.chars().filter(|&c| !c.is_whitespace()).collect();
339
340 let oob_notes_bytes = if let Ok(oob_notes_bytes) =
341 base32::decode_prefixed_bytes(FEDIMINT_PREFIX, &s)
342 {
343 oob_notes_bytes
344 } else if let Ok(oob_notes_bytes) = BASE64_URL_SAFE.decode(&s) {
345 oob_notes_bytes
346 } else if let Ok(oob_notes_bytes) = base64::engine::general_purpose::STANDARD.decode(&s) {
347 oob_notes_bytes
348 } else {
349 bail!("OOBNotes were not a well-formed base64(URL-safe) or base32 string");
350 };
351
352 let oob_notes =
353 OOBNotes::consensus_decode_whole(&oob_notes_bytes, &ModuleDecoderRegistry::default())?;
354
355 ensure!(!oob_notes.notes().is_empty(), "OOBNotes cannot be empty");
356
357 Ok(oob_notes)
358 }
359}
360
361impl Display for OOBNotes {
362 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
363 let bytes = Encodable::consensus_encode_to_vec(self);
364
365 f.write_str(&BASE64_URL_SAFE.encode(&bytes))
366 }
367}
368
369impl Serialize for OOBNotes {
370 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
371 where
372 S: serde::Serializer,
373 {
374 serializer.serialize_str(&self.to_string())
375 }
376}
377
378impl<'de> Deserialize<'de> for OOBNotes {
379 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
380 where
381 D: serde::Deserializer<'de>,
382 {
383 let s = String::deserialize(deserializer)?;
384 FromStr::from_str(&s).map_err(serde::de::Error::custom)
385 }
386}
387
388impl OOBNotes {
389 pub fn total_amount(&self) -> Amount {
391 self.notes().total_amount()
392 }
393}
394
395#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
398pub enum ReissueExternalNotesState {
399 Created,
402 Issuing,
405 Done,
407 Failed(String),
409}
410
411#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
414pub enum SpendOOBState {
415 Created,
417 UserCanceledProcessing,
420 UserCanceledSuccess,
423 UserCanceledFailure,
426 Success,
430 Refunded,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct MintOperationMeta {
438 pub variant: MintOperationMetaVariant,
439 pub amount: Amount,
440 pub extra_meta: serde_json::Value,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
444#[serde(rename_all = "snake_case")]
445pub enum MintOperationMetaVariant {
446 Reissuance {
450 #[serde(skip_serializing, default, rename = "out_point")]
452 legacy_out_point: Option<OutPoint>,
453 #[serde(default)]
455 txid: Option<TransactionId>,
456 #[serde(default)]
458 out_point_indices: Vec<u64>,
459 },
460 SpendOOB {
461 requested_amount: Amount,
462 oob_notes: OOBNotes,
463 },
464 Recovery {
475 out_point_ranges: Vec<OutPointRange>,
478 },
479}
480
481#[derive(Debug, Clone)]
482pub struct MintClientInit;
483
484impl ModuleInit for MintClientInit {
485 type Common = MintCommonInit;
486
487 async fn dump_database(
488 &self,
489 dbtx: &mut DatabaseTransaction<'_>,
490 prefix_names: Vec<String>,
491 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
492 let mut mint_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
493 BTreeMap::new();
494 let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
495 prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
496 });
497
498 for table in filtered_prefixes {
499 match table {
500 DbKeyPrefix::Note => {
501 push_db_pair_items!(
502 dbtx,
503 NoteKeyPrefix,
504 NoteKey,
505 SpendableNoteUndecoded,
506 mint_client_items,
507 "Notes"
508 );
509 }
510 DbKeyPrefix::NextECashNoteIndex => {
511 push_db_pair_items!(
512 dbtx,
513 NextECashNoteIndexKeyPrefix,
514 NextECashNoteIndexKey,
515 u64,
516 mint_client_items,
517 "NextECashNoteIndex"
518 );
519 }
520 DbKeyPrefix::CancelledOOBSpend => {
521 push_db_pair_items!(
522 dbtx,
523 CancelledOOBSpendKeyPrefix,
524 CancelledOOBSpendKey,
525 (),
526 mint_client_items,
527 "CancelledOOBSpendKey"
528 );
529 }
530 DbKeyPrefix::RecoveryFinalized => {
531 if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await {
532 mint_client_items.insert("RecoveryFinalized".to_string(), Box::new(val));
533 }
534 }
535 DbKeyPrefix::RecoveryState
536 | DbKeyPrefix::ReusedNoteIndices
537 | DbKeyPrefix::ExternalReservedStart
538 | DbKeyPrefix::CoreInternalReservedStart
539 | DbKeyPrefix::CoreInternalReservedEnd => {}
540 }
541 }
542
543 Box::new(mint_client_items.into_iter())
544 }
545}
546
547#[apply(async_trait_maybe_send!)]
548impl ClientModuleInit for MintClientInit {
549 type Module = MintClientModule;
550
551 fn supported_api_versions(&self) -> MultiApiVersion {
552 MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
553 .expect("no version conflicts")
554 }
555
556 async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
557 Ok(MintClientModule {
558 federation_id: *args.federation_id(),
559 cfg: args.cfg().clone(),
560 secret: args.module_root_secret().clone(),
561 secp: Secp256k1::new(),
562 notifier: args.notifier().clone(),
563 client_ctx: args.context(),
564 balance_update_sender: tokio::sync::watch::channel(()).0,
565 })
566 }
567
568 async fn recover(
569 &self,
570 args: &ClientModuleRecoverArgs<Self>,
571 snapshot: Option<&<Self::Module as ClientModule>::Backup>,
572 ) -> anyhow::Result<()> {
573 args.recover_from_history::<MintRecovery>(self, snapshot)
574 .await
575 }
576
577 fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
578 let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = BTreeMap::new();
579 migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
580 Box::pin(migrate_to_v1(dbtx))
581 });
582 migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
583 Box::pin(async { migrate_state(active_states, inactive_states, migrate_state_to_v2) })
584 });
585
586 migrations
587 }
588
589 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
590 Some(
591 DbKeyPrefix::iter()
592 .map(|p| p as u8)
593 .chain(
594 DbKeyPrefix::ExternalReservedStart as u8
595 ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
596 )
597 .collect(),
598 )
599 }
600}
601
602pub struct MintClientModule {
628 federation_id: FederationId,
629 cfg: MintClientConfig,
630 secret: DerivableSecret,
631 secp: Secp256k1<All>,
632 notifier: ModuleNotifier<MintClientStateMachines>,
633 pub client_ctx: ClientContext<Self>,
634 balance_update_sender: tokio::sync::watch::Sender<()>,
635}
636
637impl fmt::Debug for MintClientModule {
638 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
639 f.debug_struct("MintClientModule")
640 .field("federation_id", &self.federation_id)
641 .field("cfg", &self.cfg)
642 .field("notifier", &self.notifier)
643 .field("client_ctx", &self.client_ctx)
644 .finish_non_exhaustive()
645 }
646}
647
648#[derive(Clone)]
650pub struct MintClientContext {
651 pub federation_id: FederationId,
652 pub client_ctx: ClientContext<MintClientModule>,
653 pub mint_decoder: Decoder,
654 pub tbs_pks: Tiered<AggregatePublicKey>,
655 pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>,
656 pub secret: DerivableSecret,
657 pub module_db: Database,
660 pub balance_update_sender: tokio::sync::watch::Sender<()>,
662}
663
664impl fmt::Debug for MintClientContext {
665 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
666 f.debug_struct("MintClientContext")
667 .field("federation_id", &self.federation_id)
668 .finish_non_exhaustive()
669 }
670}
671
672impl MintClientContext {
673 fn await_cancel_oob_payment(&self, operation_id: OperationId) -> BoxFuture<'static, ()> {
674 let db = self.module_db.clone();
675 Box::pin(async move {
676 db.wait_key_exists(&CancelledOOBSpendKey(operation_id))
677 .await;
678 })
679 }
680}
681
682impl Context for MintClientContext {
683 const KIND: Option<ModuleKind> = Some(KIND);
684}
685
686#[apply(async_trait_maybe_send!)]
687impl ClientModule for MintClientModule {
688 type Init = MintClientInit;
689 type Common = MintModuleTypes;
690 type Backup = EcashBackup;
691 type ModuleStateMachineContext = MintClientContext;
692 type States = MintClientStateMachines;
693
694 fn context(&self) -> Self::ModuleStateMachineContext {
695 MintClientContext {
696 federation_id: self.federation_id,
697 client_ctx: self.client_ctx.clone(),
698 mint_decoder: self.decoder(),
699 tbs_pks: self.cfg.tbs_pks.clone(),
700 peer_tbs_pks: self.cfg.peer_tbs_pks.clone(),
701 secret: self.secret.clone(),
702 module_db: self.client_ctx.module_db().clone(),
703 balance_update_sender: self.balance_update_sender.clone(),
704 }
705 }
706
707 fn input_fee(
708 &self,
709 amount: &Amounts,
710 _input: &<Self::Common as ModuleCommon>::Input,
711 ) -> Option<Amounts> {
712 Some(Amounts::new_bitcoin(
713 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
714 ))
715 }
716
717 fn output_fee(
718 &self,
719 amount: &Amounts,
720 _output: &<Self::Common as ModuleCommon>::Output,
721 ) -> Option<Amounts> {
722 Some(Amounts::new_bitcoin(
723 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
724 ))
725 }
726
727 #[cfg(feature = "cli")]
728 async fn handle_cli_command(
729 &self,
730 args: &[std::ffi::OsString],
731 ) -> anyhow::Result<serde_json::Value> {
732 cli::handle_cli_command(self, args).await
733 }
734
735 fn supports_backup(&self) -> bool {
736 true
737 }
738
739 async fn backup(&self) -> anyhow::Result<EcashBackup> {
740 self.client_ctx
741 .module_db()
742 .autocommit(
743 |dbtx_ctx, _| {
744 Box::pin(async { self.prepare_plaintext_ecash_backup(dbtx_ctx).await })
745 },
746 None,
747 )
748 .await
749 .map_err(|e| match e {
750 AutocommitError::ClosureError { error, .. } => error,
751 AutocommitError::CommitFailed { last_error, .. } => {
752 anyhow!("Commit to DB failed: {last_error}")
753 }
754 })
755 }
756
757 fn supports_being_primary(&self) -> PrimaryModuleSupport {
758 PrimaryModuleSupport::selected(PrimaryModulePriority::HIGH, [AmountUnit::BITCOIN])
759 }
760
761 async fn create_final_inputs_and_outputs(
762 &self,
763 dbtx: &mut DatabaseTransaction<'_>,
764 operation_id: OperationId,
765 unit: AmountUnit,
766 mut input_amount: Amount,
767 mut output_amount: Amount,
768 ) -> anyhow::Result<(
769 ClientInputBundle<MintInput, MintClientStateMachines>,
770 ClientOutputBundle<MintOutput, MintClientStateMachines>,
771 )> {
772 let consolidation_inputs = self.consolidate_notes(dbtx).await?;
773
774 if unit != AmountUnit::BITCOIN {
775 bail!("Module can only handle Bitcoin");
776 }
777
778 input_amount += consolidation_inputs
779 .iter()
780 .map(|input| input.0.amounts.get_bitcoin())
781 .sum();
782
783 output_amount += consolidation_inputs
784 .iter()
785 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
786 .sum();
787
788 let additional_inputs = self
789 .create_sufficient_input(dbtx, output_amount.saturating_sub(input_amount))
790 .await?;
791
792 input_amount += additional_inputs
793 .iter()
794 .map(|input| input.0.amounts.get_bitcoin())
795 .sum();
796
797 output_amount += additional_inputs
798 .iter()
799 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
800 .sum();
801
802 let outputs = self
803 .create_output(
804 dbtx,
805 operation_id,
806 2,
807 input_amount.saturating_sub(output_amount),
808 )
809 .await;
810
811 Ok((
812 create_bundle_for_inputs(
813 [consolidation_inputs, additional_inputs].concat(),
814 operation_id,
815 ),
816 outputs,
817 ))
818 }
819
820 async fn await_primary_module_output(
821 &self,
822 operation_id: OperationId,
823 out_point: OutPoint,
824 ) -> anyhow::Result<()> {
825 self.await_output_finalized(operation_id, out_point).await
826 }
827
828 async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
829 if unit != AmountUnit::BITCOIN {
830 return Amount::ZERO;
831 }
832 self.get_note_counts_by_denomination(dbtx)
833 .await
834 .total_amount()
835 }
836
837 async fn get_balances(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
838 Amounts::new_bitcoin(
839 <Self as ClientModule>::get_balance(self, dbtx, AmountUnit::BITCOIN).await,
840 )
841 }
842
843 async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
844 Box::pin(tokio_stream::wrappers::WatchStream::new(
845 self.balance_update_sender.subscribe(),
846 ))
847 }
848
849 async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
850 let balance = ClientModule::get_balances(self, dbtx).await;
851
852 for (unit, amount) in balance {
853 if Amount::from_units(0) < amount {
854 bail!("Outstanding balance: {amount}, unit: {unit:?}");
855 }
856 }
857
858 if !self.client_ctx.get_own_active_states().await.is_empty() {
859 bail!("Pending operations")
860 }
861 Ok(())
862 }
863
864 async fn handle_rpc(
865 &self,
866 method: String,
867 request: serde_json::Value,
868 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
869 Box::pin(try_stream! {
870 match method.as_str() {
871 "reissue_external_notes" => {
872 let req: ReissueExternalNotesRequest = serde_json::from_value(request)?;
873 let result = self.reissue_external_notes(req.oob_notes, req.extra_meta).await?;
874 yield serde_json::to_value(result)?;
875 }
876 "subscribe_reissue_external_notes" => {
877 let req: SubscribeReissueExternalNotesRequest = serde_json::from_value(request)?;
878 let stream = self.subscribe_reissue_external_notes(req.operation_id).await?;
879 for await state in stream.into_stream() {
880 yield serde_json::to_value(state)?;
881 }
882 }
883 "spend_notes" => {
884 let req: SpendNotesRequest = serde_json::from_value(request)?;
885 let result = self.spend_notes_with_selector(
886 &SelectNotesWithExactAmount,
887 req.amount,
888 req.try_cancel_after,
889 req.include_invite,
890 req.extra_meta
891 ).await?;
892 yield serde_json::to_value(result)?;
893 }
894 "spend_notes_expert" => {
895 let req: SpendNotesExpertRequest = serde_json::from_value(request)?;
896 let result = self.spend_notes_with_selector(
897 &SelectNotesWithAtleastAmount,
898 req.min_amount,
899 req.try_cancel_after,
900 req.include_invite,
901 req.extra_meta
902 ).await?;
903 yield serde_json::to_value(result)?;
904 }
905 "validate_notes" => {
906 let req: ValidateNotesRequest = serde_json::from_value(request)?;
907 let result = self.validate_notes(&req.oob_notes)?;
908 yield serde_json::to_value(result)?;
909 }
910 "try_cancel_spend_notes" => {
911 let req: TryCancelSpendNotesRequest = serde_json::from_value(request)?;
912 let result = self.try_cancel_spend_notes(req.operation_id).await;
913 yield serde_json::to_value(result)?;
914 }
915 "subscribe_spend_notes" => {
916 let req: SubscribeSpendNotesRequest = serde_json::from_value(request)?;
917 let stream = self.subscribe_spend_notes(req.operation_id).await?;
918 for await state in stream.into_stream() {
919 yield serde_json::to_value(state)?;
920 }
921 }
922 "await_spend_oob_refund" => {
923 let req: AwaitSpendOobRefundRequest = serde_json::from_value(request)?;
924 let value = self.await_spend_oob_refund(req.operation_id).await;
925 yield serde_json::to_value(value)?;
926 }
927 "note_counts_by_denomination" => {
928 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
929 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
930 yield serde_json::to_value(note_counts)?;
931 }
932 _ => {
933 Err(anyhow::format_err!("Unknown method: {}", method))?;
934 unreachable!()
935 },
936 }
937 })
938 }
939}
940
941#[derive(Deserialize)]
942struct ReissueExternalNotesRequest {
943 oob_notes: OOBNotes,
944 extra_meta: serde_json::Value,
945}
946
947#[derive(Deserialize)]
948struct SubscribeReissueExternalNotesRequest {
949 operation_id: OperationId,
950}
951
952#[derive(Deserialize)]
955struct SpendNotesExpertRequest {
956 min_amount: Amount,
957 try_cancel_after: Duration,
958 include_invite: bool,
959 extra_meta: serde_json::Value,
960}
961
962#[derive(Deserialize)]
963struct SpendNotesRequest {
964 amount: Amount,
965 try_cancel_after: Duration,
966 include_invite: bool,
967 extra_meta: serde_json::Value,
968}
969
970#[derive(Deserialize)]
971struct ValidateNotesRequest {
972 oob_notes: OOBNotes,
973}
974
975#[derive(Deserialize)]
976struct TryCancelSpendNotesRequest {
977 operation_id: OperationId,
978}
979
980#[derive(Deserialize)]
981struct SubscribeSpendNotesRequest {
982 operation_id: OperationId,
983}
984
985#[derive(Deserialize)]
986struct AwaitSpendOobRefundRequest {
987 operation_id: OperationId,
988}
989
990#[derive(thiserror::Error, Debug, Clone)]
991pub enum ReissueExternalNotesError {
992 #[error("Federation ID does not match")]
993 WrongFederationId,
994 #[error("We already reissued these notes")]
995 AlreadyReissued,
996}
997
998impl MintClientModule {
999 async fn create_sufficient_input(
1000 &self,
1001 dbtx: &mut DatabaseTransaction<'_>,
1002 min_amount: Amount,
1003 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1004 if min_amount == Amount::ZERO {
1005 return Ok(vec![]);
1006 }
1007
1008 let selected_notes = Self::select_notes(
1009 dbtx,
1010 &SelectNotesWithAtleastAmount,
1011 min_amount,
1012 self.cfg.fee_consensus.clone(),
1013 )
1014 .await?;
1015
1016 for (amount, note) in selected_notes.iter_items() {
1017 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as sufficient input to fund a tx");
1018 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1019 }
1020
1021 let sender = self.balance_update_sender.clone();
1022 dbtx.on_commit(move || sender.send_replace(()));
1023
1024 let inputs = self.create_input_from_notes(selected_notes)?;
1025
1026 assert!(!inputs.is_empty());
1027
1028 Ok(inputs)
1029 }
1030
1031 #[deprecated(
1033 since = "0.5.0",
1034 note = "Use `get_note_counts_by_denomination` instead"
1035 )]
1036 pub async fn get_notes_tier_counts(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1037 self.get_note_counts_by_denomination(dbtx).await
1038 }
1039
1040 pub async fn get_available_notes_by_tier_counts(
1044 &self,
1045 dbtx: &mut DatabaseTransaction<'_>,
1046 counts: TieredCounts,
1047 ) -> (TieredMulti<SpendableNoteUndecoded>, TieredCounts) {
1048 dbtx.find_by_prefix(&NoteKeyPrefix)
1049 .await
1050 .fold(
1051 (TieredMulti::<SpendableNoteUndecoded>::default(), counts),
1052 |(mut notes, mut counts), (key, note)| async move {
1053 let amount = key.amount;
1054 if 0 < counts.get(amount) {
1055 counts.dec(amount);
1056 notes.push(amount, note);
1057 }
1058
1059 (notes, counts)
1060 },
1061 )
1062 .await
1063 }
1064
1065 pub async fn create_output(
1070 &self,
1071 dbtx: &mut DatabaseTransaction<'_>,
1072 operation_id: OperationId,
1073 notes_per_denomination: u16,
1074 exact_amount: Amount,
1075 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1076 if exact_amount == Amount::ZERO {
1077 return ClientOutputBundle::new(vec![], vec![]);
1078 }
1079
1080 let denominations = represent_amount(
1081 exact_amount,
1082 &self.get_note_counts_by_denomination(dbtx).await,
1083 &self.cfg.tbs_pks,
1084 notes_per_denomination,
1085 &self.cfg.fee_consensus,
1086 );
1087
1088 let mut outputs = Vec::new();
1089 let mut issuance_requests = Vec::new();
1090
1091 for (amount, num) in denominations.iter() {
1092 for _ in 0..num {
1093 let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await;
1094
1095 debug!(
1096 %amount,
1097 "Generated issuance request"
1098 );
1099
1100 outputs.push(ClientOutput {
1101 output: MintOutput::new_v0(amount, blind_nonce),
1102 amounts: Amounts::new_bitcoin(amount),
1103 });
1104
1105 issuance_requests.push((amount, issuance_request));
1106 }
1107 }
1108
1109 let state_generator = Arc::new(move |out_point_range: OutPointRange| {
1110 assert_eq!(out_point_range.count(), issuance_requests.len());
1111 vec![MintClientStateMachines::Output(MintOutputStateMachine {
1112 common: MintOutputCommon {
1113 operation_id,
1114 out_point_range,
1115 },
1116 state: MintOutputStates::CreatedMulti(MintOutputStatesCreatedMulti {
1117 issuance_requests: out_point_range
1118 .into_iter()
1119 .map(|out_point| out_point.out_idx)
1120 .zip(issuance_requests.clone())
1121 .collect(),
1122 }),
1123 })]
1124 });
1125
1126 ClientOutputBundle::new(
1127 outputs,
1128 vec![ClientOutputSM {
1129 state_machines: state_generator,
1130 }],
1131 )
1132 }
1133
1134 pub async fn get_note_counts_by_denomination(
1136 &self,
1137 dbtx: &mut DatabaseTransaction<'_>,
1138 ) -> TieredCounts {
1139 dbtx.find_by_prefix(&NoteKeyPrefix)
1140 .await
1141 .fold(
1142 TieredCounts::default(),
1143 |mut acc, (key, _note)| async move {
1144 acc.inc(key.amount, 1);
1145 acc
1146 },
1147 )
1148 .await
1149 }
1150
1151 #[deprecated(
1153 since = "0.5.0",
1154 note = "Use `get_note_counts_by_denomination` instead"
1155 )]
1156 pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1157 self.get_note_counts_by_denomination(dbtx).await
1158 }
1159
1160 pub async fn estimate_spend_all_fees(&self) -> Amount {
1166 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
1167 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
1168
1169 note_counts
1170 .iter()
1171 .filter_map(|(amount, count)| {
1172 let note_fee = self.cfg.fee_consensus.fee(amount);
1173 if note_fee < amount {
1174 note_fee.checked_mul(count as u64)
1175 } else {
1176 None
1177 }
1178 })
1179 .fold(Amount::ZERO, |acc, fee| {
1180 acc.checked_add(fee).expect("fee sum overflow")
1181 })
1182 }
1183
1184 pub async fn await_output_finalized(
1188 &self,
1189 operation_id: OperationId,
1190 out_point: OutPoint,
1191 ) -> anyhow::Result<()> {
1192 let stream = self
1193 .notifier
1194 .subscribe(operation_id)
1195 .await
1196 .filter_map(|state| async {
1197 let MintClientStateMachines::Output(state) = state else {
1198 return None;
1199 };
1200
1201 if state.common.txid() != out_point.txid
1202 || !state
1203 .common
1204 .out_point_range
1205 .out_idx_iter()
1206 .contains(&out_point.out_idx)
1207 {
1208 return None;
1209 }
1210
1211 match state.state {
1212 MintOutputStates::Succeeded(_) => Some(Ok(())),
1213 MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
1214 MintOutputStates::Failed(failed) => Some(Err(anyhow!(
1215 "Failed to finalize transaction: {}",
1216 failed.error
1217 ))),
1218 MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1219 }
1220 });
1221 pin_mut!(stream);
1222
1223 stream.next_or_pending().await
1224 }
1225
1226 pub async fn consolidate_notes(
1233 &self,
1234 dbtx: &mut DatabaseTransaction<'_>,
1235 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1236 const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1239 const MIN_NOTES_PER_TIER: usize = 4;
1241 const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1244 #[allow(clippy::assertions_on_constants)]
1246 {
1247 assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1248 }
1249
1250 let counts = self.get_note_counts_by_denomination(dbtx).await;
1251
1252 let should_consolidate = counts
1253 .iter()
1254 .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1255
1256 if !should_consolidate {
1257 return Ok(vec![]);
1258 }
1259
1260 let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1261
1262 let excessive_counts: TieredCounts = counts
1263 .iter()
1264 .map(|(amount, count)| {
1265 let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1266
1267 max_count -= take;
1268 (amount, take)
1269 })
1270 .collect();
1271
1272 let (selected_notes, unavailable) = self
1273 .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1274 .await;
1275
1276 debug_assert!(
1277 unavailable.is_empty(),
1278 "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1279 );
1280
1281 if !selected_notes.is_empty() {
1282 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");
1283 }
1284
1285 let mut selected_notes_decoded = vec![];
1286 for (amount, note) in selected_notes.iter_items() {
1287 let spendable_note_decoded = note.decode()?;
1288 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1289 Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1290 .await;
1291 selected_notes_decoded.push((amount, spendable_note_decoded));
1292 }
1293
1294 let sender = self.balance_update_sender.clone();
1295 dbtx.on_commit(move || sender.send_replace(()));
1296
1297 self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1298 }
1299
1300 #[allow(clippy::type_complexity)]
1302 pub fn create_input_from_notes(
1303 &self,
1304 notes: TieredMulti<SpendableNote>,
1305 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1306 let mut inputs_and_notes = Vec::new();
1307
1308 for (amount, spendable_note) in notes.into_iter_items() {
1309 let key = self
1310 .cfg
1311 .tbs_pks
1312 .get(amount)
1313 .ok_or(anyhow!("Invalid amount tier: {amount}"))?;
1314
1315 let note = spendable_note.note();
1316
1317 if !note.verify(*key) {
1318 bail!("Invalid note");
1319 }
1320
1321 inputs_and_notes.push((
1322 ClientInput {
1323 input: MintInput::new_v0(amount, note),
1324 keys: vec![spendable_note.spend_key],
1325 amounts: Amounts::new_bitcoin(amount),
1326 },
1327 spendable_note,
1328 ));
1329 }
1330
1331 Ok(inputs_and_notes)
1332 }
1333
1334 async fn spend_notes_oob(
1335 &self,
1336 dbtx: &mut DatabaseTransaction<'_>,
1337 notes_selector: &impl NotesSelector,
1338 amount: Amount,
1339 try_cancel_after: Duration,
1340 ) -> anyhow::Result<(
1341 OperationId,
1342 Vec<MintClientStateMachines>,
1343 TieredMulti<SpendableNote>,
1344 )> {
1345 ensure!(
1346 amount > Amount::ZERO,
1347 "zero-amount out-of-band spends are not supported"
1348 );
1349
1350 let selected_notes =
1351 Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1352
1353 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1354
1355 for (amount, note) in selected_notes.iter_items() {
1356 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1357 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1358 }
1359
1360 let sender = self.balance_update_sender.clone();
1361 dbtx.on_commit(move || sender.send_replace(()));
1362
1363 let state_machines = vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1364 operation_id,
1365 state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1366 spendable_notes: selected_notes.clone().into_iter_items().collect(),
1367 timeout: fedimint_core::time::now() + try_cancel_after,
1368 }),
1369 })];
1370
1371 Ok((operation_id, state_machines, selected_notes))
1372 }
1373
1374 pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1375 Box::pin(
1376 self.notifier
1377 .subscribe(operation_id)
1378 .await
1379 .filter_map(|state| async {
1380 let MintClientStateMachines::OOB(state) = state else {
1381 return None;
1382 };
1383
1384 match state.state {
1385 MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1386 user_triggered: false,
1387 transaction_ids: vec![refund.refund_txid],
1388 }),
1389 MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1390 user_triggered: true,
1391 transaction_ids: vec![refund.refund_txid],
1392 }),
1393 MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1394 user_triggered: true,
1395 transaction_ids: vec![refund.refund_txid],
1396 }),
1397 MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1398 }
1399 }),
1400 )
1401 .next_or_pending()
1402 .await
1403 }
1404
1405 async fn select_notes(
1407 dbtx: &mut DatabaseTransaction<'_>,
1408 notes_selector: &impl NotesSelector,
1409 requested_amount: Amount,
1410 fee_consensus: FeeConsensus,
1411 ) -> anyhow::Result<TieredMulti<SpendableNote>> {
1412 let note_stream = dbtx
1413 .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1414 .await
1415 .map(|(key, note)| (key.amount, note));
1416
1417 notes_selector
1418 .select_notes(note_stream, requested_amount, fee_consensus)
1419 .await?
1420 .into_iter_items()
1421 .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1422 .collect::<anyhow::Result<TieredMulti<_>>>()
1423 }
1424
1425 async fn get_all_spendable_notes(
1426 dbtx: &mut DatabaseTransaction<'_>,
1427 ) -> TieredMulti<SpendableNoteUndecoded> {
1428 (dbtx
1429 .find_by_prefix(&NoteKeyPrefix)
1430 .await
1431 .map(|(key, note)| (key.amount, note))
1432 .collect::<Vec<_>>()
1433 .await)
1434 .into_iter()
1435 .collect()
1436 }
1437
1438 async fn get_next_note_index(
1439 &self,
1440 dbtx: &mut DatabaseTransaction<'_>,
1441 amount: Amount,
1442 ) -> NoteIndex {
1443 NoteIndex(
1444 dbtx.get_value(&NextECashNoteIndexKey(amount))
1445 .await
1446 .unwrap_or(0),
1447 )
1448 }
1449
1450 pub fn new_note_secret_static(
1466 secret: &DerivableSecret,
1467 amount: Amount,
1468 note_idx: NoteIndex,
1469 ) -> DerivableSecret {
1470 assert_eq!(secret.level(), 2);
1471 debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1472 secret
1473 .child_key(MINT_E_CASH_TYPE_CHILD_ID) .child_key(ChildId(note_idx.as_u64()))
1475 .child_key(ChildId(amount.msats))
1476 }
1477
1478 async fn new_note_secret(
1482 &self,
1483 amount: Amount,
1484 dbtx: &mut DatabaseTransaction<'_>,
1485 ) -> DerivableSecret {
1486 let new_idx = self.get_next_note_index(dbtx, amount).await;
1487 dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1488 .await;
1489 Self::new_note_secret_static(&self.secret, amount, new_idx)
1490 }
1491
1492 pub async fn new_ecash_note(
1493 &self,
1494 amount: Amount,
1495 dbtx: &mut DatabaseTransaction<'_>,
1496 ) -> (NoteIssuanceRequest, BlindNonce) {
1497 let secret = self.new_note_secret(amount, dbtx).await;
1498 NoteIssuanceRequest::new(&self.secp, &secret)
1499 }
1500
1501 pub async fn reissue_external_notes<M: Serialize + Send>(
1506 &self,
1507 oob_notes: OOBNotes,
1508 extra_meta: M,
1509 ) -> anyhow::Result<OperationId> {
1510 let notes = oob_notes.notes().clone();
1511 let federation_id_prefix = oob_notes.federation_id_prefix();
1512
1513 ensure!(
1514 notes.total_amount() > Amount::ZERO,
1515 "Reissuing zero-amount e-cash isn't supported"
1516 );
1517
1518 if federation_id_prefix != self.federation_id.to_prefix() {
1519 bail!(ReissueExternalNotesError::WrongFederationId);
1520 }
1521
1522 let operation_id = OperationId(
1523 notes
1524 .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
1525 .to_byte_array(),
1526 );
1527
1528 let amount = notes.total_amount();
1529 let mint_inputs = self.create_input_from_notes(notes)?;
1530
1531 let tx = TransactionBuilder::new().with_inputs(
1532 self.client_ctx
1533 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
1534 );
1535
1536 let extra_meta = serde_json::to_value(extra_meta)
1537 .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
1538 let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
1539 variant: MintOperationMetaVariant::Reissuance {
1540 legacy_out_point: None,
1541 txid: Some(change_range.txid()),
1542 out_point_indices: change_range
1543 .into_iter()
1544 .map(|out_point| out_point.out_idx)
1545 .collect(),
1546 },
1547 amount,
1548 extra_meta: extra_meta.clone(),
1549 };
1550
1551 self.client_ctx
1552 .finalize_and_submit_transaction(
1553 operation_id,
1554 MintCommonInit::KIND.as_str(),
1555 operation_meta_gen,
1556 tx,
1557 )
1558 .await
1559 .context(ReissueExternalNotesError::AlreadyReissued)?;
1560
1561 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1562
1563 self.client_ctx
1564 .log_event(&mut dbtx, OOBNotesReissued { amount })
1565 .await;
1566
1567 self.client_ctx
1568 .log_event(
1569 &mut dbtx,
1570 ReceivePaymentEvent {
1571 operation_id,
1572 amount,
1573 },
1574 )
1575 .await;
1576
1577 dbtx.commit_tx().await;
1578
1579 Ok(operation_id)
1580 }
1581
1582 pub async fn subscribe_reissue_external_notes(
1585 &self,
1586 operation_id: OperationId,
1587 ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
1588 let operation = self.mint_operation(operation_id).await?;
1589 let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
1590 MintOperationMetaVariant::Reissuance {
1591 legacy_out_point,
1592 txid,
1593 out_point_indices,
1594 } => {
1595 let txid = txid
1598 .or(legacy_out_point.map(|out_point| out_point.txid))
1599 .context("Empty reissuance not permitted, this should never happen")?;
1600
1601 let out_points = out_point_indices
1602 .into_iter()
1603 .map(|out_idx| OutPoint { txid, out_idx })
1604 .chain(legacy_out_point)
1605 .collect::<Vec<_>>();
1606
1607 (txid, out_points)
1608 }
1609 MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"),
1610 MintOperationMetaVariant::Recovery { .. } => unimplemented!(),
1611 };
1612
1613 let client_ctx = self.client_ctx.clone();
1614
1615 Ok(self.client_ctx.outcome_or_updates(operation, operation_id, move || {
1616 stream! {
1617 yield ReissueExternalNotesState::Created;
1618
1619 match client_ctx
1620 .transaction_updates(operation_id)
1621 .await
1622 .await_tx_accepted(txid)
1623 .await
1624 {
1625 Ok(()) => {
1626 yield ReissueExternalNotesState::Issuing;
1627 }
1628 Err(e) => {
1629 yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
1630 return;
1631 }
1632 }
1633
1634 for out_point in out_points {
1635 if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
1636 yield ReissueExternalNotesState::Failed(e.to_string());
1637 return;
1638 }
1639 }
1640 yield ReissueExternalNotesState::Done;
1641 }}
1642 ))
1643 }
1644
1645 #[deprecated(
1657 since = "0.5.0",
1658 note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
1659 )]
1660 pub async fn spend_notes<M: Serialize + Send>(
1661 &self,
1662 min_amount: Amount,
1663 try_cancel_after: Duration,
1664 include_invite: bool,
1665 extra_meta: M,
1666 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1667 self.spend_notes_with_selector(
1668 &SelectNotesWithAtleastAmount,
1669 min_amount,
1670 try_cancel_after,
1671 include_invite,
1672 extra_meta,
1673 )
1674 .await
1675 }
1676
1677 pub async fn spend_notes_with_selector<M: Serialize + Send>(
1693 &self,
1694 notes_selector: &impl NotesSelector,
1695 requested_amount: Amount,
1696 try_cancel_after: Duration,
1697 include_invite: bool,
1698 extra_meta: M,
1699 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1700 let federation_id_prefix = self.federation_id.to_prefix();
1701 let extra_meta = serde_json::to_value(extra_meta)
1702 .expect("MintClientModule::spend_notes extra_meta is serializable");
1703
1704 self.client_ctx
1705 .module_db()
1706 .autocommit(
1707 |dbtx, _| {
1708 let extra_meta = extra_meta.clone();
1709 Box::pin(async {
1710 let (operation_id, states, notes) = self
1711 .spend_notes_oob(
1712 dbtx,
1713 notes_selector,
1714 requested_amount,
1715 try_cancel_after,
1716 )
1717 .await?;
1718
1719 let oob_notes = if include_invite {
1720 OOBNotes::new_with_invite(
1721 notes,
1722 &self.client_ctx.get_invite_code().await,
1723 )
1724 } else {
1725 OOBNotes::new(federation_id_prefix, notes)
1726 };
1727
1728 self.client_ctx
1729 .add_state_machines_dbtx(
1730 dbtx,
1731 self.client_ctx.map_dyn(states).collect(),
1732 )
1733 .await?;
1734 self.client_ctx
1735 .add_operation_log_entry_dbtx(
1736 dbtx,
1737 operation_id,
1738 MintCommonInit::KIND.as_str(),
1739 MintOperationMeta {
1740 variant: MintOperationMetaVariant::SpendOOB {
1741 requested_amount,
1742 oob_notes: oob_notes.clone(),
1743 },
1744 amount: oob_notes.total_amount(),
1745 extra_meta,
1746 },
1747 )
1748 .await;
1749 self.client_ctx
1750 .log_event(
1751 dbtx,
1752 OOBNotesSpent {
1753 requested_amount,
1754 spent_amount: oob_notes.total_amount(),
1755 timeout: try_cancel_after,
1756 include_invite,
1757 },
1758 )
1759 .await;
1760
1761 self.client_ctx
1762 .log_event(
1763 dbtx,
1764 SendPaymentEvent {
1765 operation_id,
1766 amount: oob_notes.total_amount(),
1767 oob_notes: oob_notes.to_string(),
1768 },
1769 )
1770 .await;
1771
1772 Ok((operation_id, oob_notes))
1773 })
1774 },
1775 Some(100),
1776 )
1777 .await
1778 .map_err(|e| match e {
1779 AutocommitError::ClosureError { error, .. } => error,
1780 AutocommitError::CommitFailed { last_error, .. } => {
1781 anyhow!("Commit to DB failed: {last_error}")
1782 }
1783 })
1784 }
1785
1786 pub async fn send_oob_notes<M: Serialize + Send>(
1812 &self,
1813 amount: Amount,
1814 extra_meta: M,
1815 ) -> anyhow::Result<OOBNotes> {
1816 let amount = Amount::from_msats(amount.msats.div_ceil(512) * 512);
1818
1819 let extra_meta = serde_json::to_value(extra_meta)
1820 .expect("MintClientModule::send_oob_notes extra_meta is serializable");
1821
1822 let oob_notes: Option<OOBNotes> = self
1824 .client_ctx
1825 .module_db()
1826 .autocommit(
1827 |dbtx, _| {
1828 let extra_meta = extra_meta.clone();
1829 Box::pin(async {
1830 self.try_spend_exact_notes_dbtx(
1831 dbtx,
1832 amount,
1833 self.federation_id,
1834 extra_meta,
1835 )
1836 .await
1837 .map(Ok::<OOBNotes, anyhow::Error>)
1838 .transpose()
1839 })
1840 },
1841 Some(100),
1842 )
1843 .await
1844 .expect("Failed to commit dbtx after 100 retries");
1845
1846 if let Some(oob_notes) = oob_notes {
1847 return Ok(oob_notes);
1848 }
1849
1850 self.client_ctx
1852 .global_api()
1853 .session_count()
1854 .await
1855 .context("Cannot reach federation to reissue notes")?;
1856
1857 let operation_id = OperationId::new_random();
1858
1859 let output_bundle = self
1861 .create_output(
1862 &mut self.client_ctx.module_db().begin_transaction_nc().await,
1863 operation_id,
1864 1, amount,
1866 )
1867 .await;
1868
1869 let combined_bundle = ClientOutputBundle::new(
1871 output_bundle.outputs().to_vec(),
1872 output_bundle.sms().to_vec(),
1873 );
1874
1875 let outputs = self.client_ctx.make_client_outputs(combined_bundle);
1876
1877 let em_clone = extra_meta.clone();
1878
1879 let out_point_range = self
1881 .client_ctx
1882 .finalize_and_submit_transaction(
1883 operation_id,
1884 MintCommonInit::KIND.as_str(),
1885 move |change_range: OutPointRange| MintOperationMeta {
1886 variant: MintOperationMetaVariant::Reissuance {
1887 legacy_out_point: None,
1888 txid: Some(change_range.txid()),
1889 out_point_indices: change_range
1890 .into_iter()
1891 .map(|out_point| out_point.out_idx)
1892 .collect(),
1893 },
1894 amount,
1895 extra_meta: em_clone.clone(),
1896 },
1897 TransactionBuilder::new().with_outputs(outputs),
1898 )
1899 .await
1900 .context("Failed to submit reissuance transaction")?;
1901
1902 self.client_ctx
1904 .await_primary_module_outputs(operation_id, out_point_range.into_iter().collect())
1905 .await
1906 .context("Failed to await output finalization")?;
1907
1908 Box::pin(self.send_oob_notes(amount, extra_meta)).await
1910 }
1911
1912 async fn try_spend_exact_notes_dbtx(
1915 &self,
1916 dbtx: &mut DatabaseTransaction<'_>,
1917 amount: Amount,
1918 federation_id: FederationId,
1919 extra_meta: serde_json::Value,
1920 ) -> Option<OOBNotes> {
1921 let selected_notes = Self::select_notes(
1922 dbtx,
1923 &SelectNotesWithExactAmount,
1924 amount,
1925 FeeConsensus::zero(),
1926 )
1927 .await
1928 .ok()?;
1929
1930 for (note_amount, note) in selected_notes.iter_items() {
1932 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, note_amount, note)
1933 .await;
1934 }
1935
1936 let sender = self.balance_update_sender.clone();
1937 dbtx.on_commit(move || sender.send_replace(()));
1938
1939 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1940
1941 let oob_notes = OOBNotes::new(federation_id.to_prefix(), selected_notes);
1942
1943 self.client_ctx
1945 .add_operation_log_entry_dbtx(
1946 dbtx,
1947 operation_id,
1948 MintCommonInit::KIND.as_str(),
1949 MintOperationMeta {
1950 variant: MintOperationMetaVariant::SpendOOB {
1951 requested_amount: amount,
1952 oob_notes: oob_notes.clone(),
1953 },
1954 amount: oob_notes.total_amount(),
1955 extra_meta,
1956 },
1957 )
1958 .await;
1959
1960 self.client_ctx
1961 .log_event(
1962 dbtx,
1963 SendPaymentEvent {
1964 operation_id,
1965 amount: oob_notes.total_amount(),
1966 oob_notes: oob_notes.to_string(),
1967 },
1968 )
1969 .await;
1970
1971 Some(oob_notes)
1972 }
1973
1974 pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> {
1980 let federation_id_prefix = oob_notes.federation_id_prefix();
1981 let notes = oob_notes.notes().clone();
1982
1983 if federation_id_prefix != self.federation_id.to_prefix() {
1984 bail!("Federation ID does not match");
1985 }
1986
1987 let tbs_pks = &self.cfg.tbs_pks;
1988
1989 for (idx, (amt, snote)) in notes.iter_items().enumerate() {
1990 let key = tbs_pks
1991 .get(amt)
1992 .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
1993
1994 let note = snote.note();
1995 if !note.verify(*key) {
1996 bail!("Note {idx} has an invalid federation signature");
1997 }
1998
1999 let expected_nonce = Nonce(snote.spend_key.public_key());
2000 if note.nonce != expected_nonce {
2001 bail!("Note {idx} cannot be spent using the supplied spend key");
2002 }
2003 }
2004
2005 Ok(notes.total_amount())
2006 }
2007
2008 pub async fn check_note_spent(&self, oob_notes: &OOBNotes) -> anyhow::Result<bool> {
2014 use crate::api::MintFederationApi;
2015
2016 let api_client = self.client_ctx.module_api();
2017 let any_spent = try_join_all(oob_notes.notes().iter().flat_map(|(_, notes)| {
2018 notes
2019 .iter()
2020 .map(|note| api_client.check_note_spent(note.nonce()))
2021 }))
2022 .await?
2023 .into_iter()
2024 .any(|spent| spent);
2025
2026 Ok(any_spent)
2027 }
2028
2029 pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
2034 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2035 dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
2036 .await;
2037 if let Err(e) = dbtx.commit_tx_result().await {
2038 warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
2039 }
2040 }
2041
2042 pub async fn subscribe_spend_notes(
2045 &self,
2046 operation_id: OperationId,
2047 ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
2048 let operation = self.mint_operation(operation_id).await?;
2049 if !matches!(
2050 operation.meta::<MintOperationMeta>().variant,
2051 MintOperationMetaVariant::SpendOOB { .. }
2052 ) {
2053 bail!("Operation is not a out-of-band spend");
2054 }
2055
2056 let client_ctx = self.client_ctx.clone();
2057
2058 Ok(self
2059 .client_ctx
2060 .outcome_or_updates(operation, operation_id, move || {
2061 stream! {
2062 yield SpendOOBState::Created;
2063
2064 let self_ref = client_ctx.self_ref();
2065
2066 let refund = self_ref
2067 .await_spend_oob_refund(operation_id)
2068 .await;
2069
2070 if refund.user_triggered {
2071 yield SpendOOBState::UserCanceledProcessing;
2072 }
2073
2074 let mut success = true;
2075
2076 for txid in refund.transaction_ids {
2077 debug!(
2078 target: LOG_CLIENT_MODULE_MINT,
2079 %txid,
2080 operation_id=%operation_id.fmt_short(),
2081 "Waiting for oob refund txid"
2082 );
2083 if client_ctx
2084 .transaction_updates(operation_id)
2085 .await
2086 .await_tx_accepted(txid)
2087 .await.is_err() {
2088 success = false;
2089 }
2090 }
2091
2092 debug!(
2093 target: LOG_CLIENT_MODULE_MINT,
2094 operation_id=%operation_id.fmt_short(),
2095 %success,
2096 "Done waiting for all refund oob txids"
2097 );
2098
2099 match (refund.user_triggered, success) {
2100 (true, true) => {
2101 yield SpendOOBState::UserCanceledSuccess;
2102 },
2103 (true, false) => {
2104 yield SpendOOBState::UserCanceledFailure;
2105 },
2106 (false, true) => {
2107 yield SpendOOBState::Refunded;
2108 },
2109 (false, false) => {
2110 yield SpendOOBState::Success;
2111 }
2112 }
2113 }
2114 }))
2115 }
2116
2117 async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
2118 let operation = self.client_ctx.get_operation(operation_id).await?;
2119
2120 if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
2121 bail!("Operation is not a mint operation");
2122 }
2123
2124 Ok(operation)
2125 }
2126
2127 async fn delete_spendable_note(
2128 client_ctx: &ClientContext<MintClientModule>,
2129 dbtx: &mut DatabaseTransaction<'_>,
2130 amount: Amount,
2131 note: &SpendableNote,
2132 ) {
2133 client_ctx
2134 .log_event(
2135 dbtx,
2136 NoteSpent {
2137 nonce: note.nonce(),
2138 },
2139 )
2140 .await;
2141 dbtx.remove_entry(&NoteKey {
2142 amount,
2143 nonce: note.nonce(),
2144 })
2145 .await
2146 .expect("Must deleted existing spendable note");
2147 }
2148
2149 pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> {
2150 let db = self.client_ctx.module_db().clone();
2151
2152 Ok(db
2153 .autocommit(
2154 |dbtx, _| {
2155 Box::pin(async {
2156 Ok::<DerivableSecret, anyhow::Error>(
2157 self.new_note_secret(amount, dbtx).await,
2158 )
2159 })
2160 },
2161 None,
2162 )
2163 .await?)
2164 }
2165
2166 pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
2169 self.client_ctx
2170 .module_db()
2171 .begin_transaction_nc()
2172 .await
2173 .get_value(&ReusedNoteIndices)
2174 .await
2175 .unwrap_or_default()
2176 .into_iter()
2177 .map(|(amount, note_idx)| {
2178 let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
2179 let (request, blind_nonce) =
2180 NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
2181 (amount, request, blind_nonce)
2182 })
2183 .collect()
2184 }
2185}
2186
2187pub fn spendable_notes_to_operation_id(
2188 spendable_selected_notes: &TieredMulti<SpendableNote>,
2189) -> OperationId {
2190 OperationId(
2191 spendable_selected_notes
2192 .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
2193 .to_byte_array(),
2194 )
2195}
2196
2197#[derive(Debug, Serialize, Deserialize, Clone)]
2198pub struct SpendOOBRefund {
2199 pub user_triggered: bool,
2200 pub transaction_ids: Vec<TransactionId>,
2201}
2202
2203#[apply(async_trait_maybe_send!)]
2206pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
2207 async fn select_notes(
2210 &self,
2211 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2213 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2214 requested_amount: Amount,
2215 fee_consensus: FeeConsensus,
2216 ) -> anyhow::Result<TieredMulti<Note>>;
2217}
2218
2219pub struct SelectNotesWithAtleastAmount;
2225
2226#[apply(async_trait_maybe_send!)]
2227impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
2228 async fn select_notes(
2229 &self,
2230 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2231 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2232 requested_amount: Amount,
2233 fee_consensus: FeeConsensus,
2234 ) -> anyhow::Result<TieredMulti<Note>> {
2235 Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
2236 }
2237}
2238
2239pub struct SelectNotesWithExactAmount;
2243
2244#[apply(async_trait_maybe_send!)]
2245impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
2246 async fn select_notes(
2247 &self,
2248 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2249 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2250 requested_amount: Amount,
2251 fee_consensus: FeeConsensus,
2252 ) -> anyhow::Result<TieredMulti<Note>> {
2253 let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
2254
2255 if notes.total_amount() != requested_amount {
2256 bail!(
2257 "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}",
2258 requested_amount,
2259 notes.total_amount()
2260 );
2261 }
2262
2263 Ok(notes)
2264 }
2265}
2266
2267async fn select_notes_from_stream<Note>(
2273 stream: impl futures::Stream<Item = (Amount, Note)>,
2274 requested_amount: Amount,
2275 fee_consensus: FeeConsensus,
2276) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
2277 if requested_amount == Amount::ZERO {
2278 return Ok(TieredMulti::default());
2279 }
2280 let mut stream = Box::pin(stream);
2281 let mut selected = vec![];
2282 let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
2287 let mut pending_amount = requested_amount;
2288 let mut previous_amount: Option<Amount> = None; loop {
2290 if let Some((note_amount, note)) = stream.next().await {
2291 assert!(
2292 previous_amount.is_none_or(|previous| previous >= note_amount),
2293 "notes are not sorted in descending order"
2294 );
2295 previous_amount = Some(note_amount);
2296
2297 if note_amount <= fee_consensus.fee(note_amount) {
2298 continue;
2299 }
2300
2301 match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2302 Ordering::Less => {
2303 pending_amount += fee_consensus.fee(note_amount);
2305 pending_amount -= note_amount;
2306 selected.push((note_amount, note));
2307 }
2308 Ordering::Greater => {
2309 last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2313 }
2314 Ordering::Equal => {
2315 selected.push((note_amount, note));
2317
2318 let notes: TieredMulti<Note> = selected.into_iter().collect();
2319
2320 assert!(
2321 notes.total_amount().msats
2322 >= requested_amount.msats
2323 + notes
2324 .iter()
2325 .map(|note| fee_consensus.fee(note.0))
2326 .sum::<Amount>()
2327 .msats
2328 );
2329
2330 return Ok(notes);
2331 }
2332 }
2333 } else {
2334 assert!(pending_amount > Amount::ZERO);
2335 if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2336 selected.truncate(checkpoint);
2339 selected.push((big_note_amount, big_note));
2341
2342 let notes: TieredMulti<Note> = selected.into_iter().collect();
2343
2344 assert!(
2345 notes.total_amount().msats
2346 >= requested_amount.msats
2347 + notes
2348 .iter()
2349 .map(|note| fee_consensus.fee(note.0))
2350 .sum::<Amount>()
2351 .msats
2352 );
2353
2354 return Ok(notes);
2356 }
2357
2358 let total_amount = requested_amount.saturating_sub(pending_amount);
2359 return Err(InsufficientBalanceError {
2361 requested_amount,
2362 total_amount,
2363 });
2364 }
2365 }
2366}
2367
2368#[derive(Debug, Clone, Error)]
2369pub struct InsufficientBalanceError {
2370 pub requested_amount: Amount,
2371 pub total_amount: Amount,
2372}
2373
2374impl std::fmt::Display for InsufficientBalanceError {
2375 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2376 write!(
2377 f,
2378 "Insufficient balance: requested {} but only {} available",
2379 self.requested_amount, self.total_amount
2380 )
2381 }
2382}
2383
2384#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2386enum MintRestoreStates {
2387 #[encodable_default]
2388 Default { variant: u64, bytes: Vec<u8> },
2389}
2390
2391#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2393pub struct MintRestoreStateMachine {
2394 operation_id: OperationId,
2395 state: MintRestoreStates,
2396}
2397
2398#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2399pub enum MintClientStateMachines {
2400 Output(MintOutputStateMachine),
2401 Input(MintInputStateMachine),
2402 OOB(MintOOBStateMachine),
2403 Restore(MintRestoreStateMachine),
2405}
2406
2407impl IntoDynInstance for MintClientStateMachines {
2408 type DynType = DynState;
2409
2410 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2411 DynState::from_typed(instance_id, self)
2412 }
2413}
2414
2415impl State for MintClientStateMachines {
2416 type ModuleContext = MintClientContext;
2417
2418 fn transitions(
2419 &self,
2420 context: &Self::ModuleContext,
2421 global_context: &DynGlobalClientContext,
2422 ) -> Vec<StateTransition<Self>> {
2423 match self {
2424 MintClientStateMachines::Output(issuance_state) => {
2425 sm_enum_variant_translation!(
2426 issuance_state.transitions(context, global_context),
2427 MintClientStateMachines::Output
2428 )
2429 }
2430 MintClientStateMachines::Input(redemption_state) => {
2431 sm_enum_variant_translation!(
2432 redemption_state.transitions(context, global_context),
2433 MintClientStateMachines::Input
2434 )
2435 }
2436 MintClientStateMachines::OOB(oob_state) => {
2437 sm_enum_variant_translation!(
2438 oob_state.transitions(context, global_context),
2439 MintClientStateMachines::OOB
2440 )
2441 }
2442 MintClientStateMachines::Restore(_) => {
2443 sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
2444 }
2445 }
2446 }
2447
2448 fn operation_id(&self) -> OperationId {
2449 match self {
2450 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
2451 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
2452 MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
2453 MintClientStateMachines::Restore(r) => r.operation_id,
2454 }
2455 }
2456}
2457
2458#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
2461pub struct SpendableNote {
2462 pub signature: tbs::Signature,
2463 pub spend_key: Keypair,
2464}
2465
2466impl fmt::Debug for SpendableNote {
2467 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2468 f.debug_struct("SpendableNote")
2469 .field("nonce", &self.nonce())
2470 .field("signature", &self.signature)
2471 .field("spend_key", &self.spend_key)
2472 .finish()
2473 }
2474}
2475impl fmt::Display for SpendableNote {
2476 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2477 self.nonce().fmt(f)
2478 }
2479}
2480
2481impl SpendableNote {
2482 pub fn nonce(&self) -> Nonce {
2483 Nonce(self.spend_key.public_key())
2484 }
2485
2486 fn note(&self) -> Note {
2487 Note {
2488 nonce: self.nonce(),
2489 signature: self.signature,
2490 }
2491 }
2492
2493 pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
2494 SpendableNoteUndecoded {
2495 signature: self
2496 .signature
2497 .consensus_encode_to_vec()
2498 .try_into()
2499 .expect("Encoded size always correct"),
2500 spend_key: self.spend_key,
2501 }
2502 }
2503}
2504
2505#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
2517pub struct SpendableNoteUndecoded {
2518 #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
2521 pub signature: [u8; 48],
2522 pub spend_key: Keypair,
2523}
2524
2525impl fmt::Display for SpendableNoteUndecoded {
2526 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2527 self.nonce().fmt(f)
2528 }
2529}
2530
2531impl fmt::Debug for SpendableNoteUndecoded {
2532 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2533 f.debug_struct("SpendableNote")
2534 .field("nonce", &self.nonce())
2535 .field("signature", &"[raw]")
2536 .field("spend_key", &self.spend_key)
2537 .finish()
2538 }
2539}
2540
2541impl SpendableNoteUndecoded {
2542 fn nonce(&self) -> Nonce {
2543 Nonce(self.spend_key.public_key())
2544 }
2545
2546 pub fn decode(self) -> anyhow::Result<SpendableNote> {
2547 Ok(SpendableNote {
2548 signature: Decodable::consensus_decode_partial_from_finite_reader(
2549 &mut self.signature.as_slice(),
2550 &ModuleRegistry::default(),
2551 )?,
2552 spend_key: self.spend_key,
2553 })
2554 }
2555}
2556
2557#[derive(
2563 Copy,
2564 Clone,
2565 Debug,
2566 Serialize,
2567 Deserialize,
2568 PartialEq,
2569 Eq,
2570 Encodable,
2571 Decodable,
2572 Default,
2573 PartialOrd,
2574 Ord,
2575)]
2576pub struct NoteIndex(u64);
2577
2578impl NoteIndex {
2579 pub fn next(self) -> Self {
2580 Self(self.0 + 1)
2581 }
2582
2583 fn prev(self) -> Option<Self> {
2584 self.0.checked_sub(0).map(Self)
2585 }
2586
2587 pub fn as_u64(self) -> u64 {
2588 self.0
2589 }
2590
2591 #[allow(unused)]
2595 pub fn from_u64(v: u64) -> Self {
2596 Self(v)
2597 }
2598
2599 pub fn advance(&mut self) {
2600 *self = self.next();
2601 }
2602}
2603
2604impl std::fmt::Display for NoteIndex {
2605 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2606 self.0.fmt(f)
2607 }
2608}
2609
2610struct OOBSpendTag;
2611
2612impl sha256t::Tag for OOBSpendTag {
2613 fn engine() -> sha256::HashEngine {
2614 let mut engine = sha256::HashEngine::default();
2615 engine.input(b"oob-spend");
2616 engine
2617 }
2618}
2619
2620struct OOBReissueTag;
2621
2622impl sha256t::Tag for OOBReissueTag {
2623 fn engine() -> sha256::HashEngine {
2624 let mut engine = sha256::HashEngine::default();
2625 engine.input(b"oob-reissue");
2626 engine
2627 }
2628}
2629
2630pub fn represent_amount<K>(
2636 amount: Amount,
2637 current_denominations: &TieredCounts,
2638 tiers: &Tiered<K>,
2639 denomination_sets: u16,
2640 fee_consensus: &FeeConsensus,
2641) -> TieredCounts {
2642 let mut remaining_amount = amount;
2643 let mut denominations = TieredCounts::default();
2644
2645 for tier in tiers.tiers() {
2647 let notes = current_denominations.get(*tier);
2648 let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
2649 let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
2650
2651 let add_notes = min(possible_notes, missing_notes);
2652 denominations.inc(*tier, add_notes as usize);
2653 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
2654 }
2655
2656 for tier in tiers.tiers().rev() {
2658 let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
2659 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
2660 denominations.inc(*tier, res as usize);
2661 }
2662
2663 let represented: u64 = denominations
2664 .iter()
2665 .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
2666 .sum();
2667
2668 assert!(represented <= amount.msats);
2669 assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
2670
2671 denominations
2672}
2673
2674pub(crate) fn create_bundle_for_inputs(
2675 inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
2676 operation_id: OperationId,
2677) -> ClientInputBundle<MintInput, MintClientStateMachines> {
2678 let mut inputs = Vec::new();
2679 let mut input_states = Vec::new();
2680
2681 for (input, spendable_note) in inputs_and_notes {
2682 input_states.push((input.amounts.clone(), spendable_note));
2683 inputs.push(input);
2684 }
2685
2686 let input_sm = Arc::new(move |out_point_range: OutPointRange| {
2687 debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
2688
2689 vec![MintClientStateMachines::Input(MintInputStateMachine {
2690 common: MintInputCommon {
2691 operation_id,
2692 out_point_range,
2693 },
2694 state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
2695 notes: input_states
2696 .iter()
2697 .map(|(amounts, note)| (amounts.expect_only_bitcoin(), *note))
2698 .collect(),
2699 }),
2700 })]
2701 });
2702
2703 ClientInputBundle::new(
2704 inputs,
2705 vec![ClientInputSM {
2706 state_machines: input_sm,
2707 }],
2708 )
2709}
2710
2711#[cfg(test)]
2712mod tests {
2713 use std::fmt::Display;
2714 use std::str::FromStr;
2715
2716 use bitcoin_hashes::Hash;
2717 use fedimint_core::base32::FEDIMINT_PREFIX;
2718 use fedimint_core::config::FederationId;
2719 use fedimint_core::encoding::Decodable;
2720 use fedimint_core::invite_code::InviteCode;
2721 use fedimint_core::module::registry::ModuleRegistry;
2722 use fedimint_core::{
2723 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
2724 };
2725 use fedimint_mint_common::config::FeeConsensus;
2726 use itertools::Itertools;
2727 use serde_json::json;
2728
2729 use crate::{
2730 MintOperationMetaVariant, OOBNotes, OOBNotesPart, SpendableNote, SpendableNoteUndecoded,
2731 represent_amount, select_notes_from_stream,
2732 };
2733
2734 #[test]
2735 fn represent_amount_targets_denomination_sets() {
2736 fn tiers(tiers: Vec<u64>) -> Tiered<()> {
2737 tiers
2738 .into_iter()
2739 .map(|tier| (Amount::from_sats(tier), ()))
2740 .collect()
2741 }
2742
2743 fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
2744 TieredCounts::from_iter(denominations)
2745 }
2746
2747 let starting = notes(vec![
2748 (Amount::from_sats(1), 1),
2749 (Amount::from_sats(2), 3),
2750 (Amount::from_sats(3), 2),
2751 ])
2752 .summary();
2753 let tiers = tiers(vec![1, 2, 3, 4]);
2754
2755 assert_eq!(
2757 represent_amount(
2758 Amount::from_sats(6),
2759 &starting,
2760 &tiers,
2761 3,
2762 &FeeConsensus::zero()
2763 ),
2764 denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
2765 );
2766
2767 assert_eq!(
2769 represent_amount(
2770 Amount::from_sats(6),
2771 &starting,
2772 &tiers,
2773 2,
2774 &FeeConsensus::zero()
2775 ),
2776 denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
2777 );
2778 }
2779
2780 #[test_log::test(tokio::test)]
2781 async fn select_notes_avg_test() {
2782 let max_amount = Amount::from_sats(1_000_000);
2783 let tiers = Tiered::gen_denominations(2, max_amount);
2784 let tiered = represent_amount::<()>(
2785 max_amount,
2786 &TieredCounts::default(),
2787 &tiers,
2788 3,
2789 &FeeConsensus::zero(),
2790 );
2791
2792 let mut total_notes = 0;
2793 for multiplier in 1..100 {
2794 let stream = reverse_sorted_note_stream(tiered.iter().collect());
2795 let select = select_notes_from_stream(
2796 stream,
2797 Amount::from_sats(multiplier * 1000),
2798 FeeConsensus::zero(),
2799 )
2800 .await;
2801 total_notes += select.unwrap().into_iter_items().count();
2802 }
2803 assert_eq!(total_notes / 100, 10);
2804 }
2805
2806 #[test_log::test(tokio::test)]
2807 async fn select_notes_returns_exact_amount_with_minimum_notes() {
2808 let f = || {
2809 reverse_sorted_note_stream(vec![
2810 (Amount::from_sats(1), 10),
2811 (Amount::from_sats(5), 10),
2812 (Amount::from_sats(20), 10),
2813 ])
2814 };
2815 assert_eq!(
2816 select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
2817 .await
2818 .unwrap(),
2819 notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
2820 );
2821 assert_eq!(
2822 select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
2823 .await
2824 .unwrap(),
2825 notes(vec![(Amount::from_sats(20), 1)])
2826 );
2827 }
2828
2829 #[test_log::test(tokio::test)]
2830 async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
2831 let stream = reverse_sorted_note_stream(vec![
2832 (Amount::from_sats(1), 1),
2833 (Amount::from_sats(5), 5),
2834 (Amount::from_sats(20), 5),
2835 ]);
2836 assert_eq!(
2837 select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
2838 .await
2839 .unwrap(),
2840 notes(vec![(Amount::from_sats(5), 2)])
2841 );
2842 }
2843
2844 #[test_log::test(tokio::test)]
2845 async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
2846 let stream = reverse_sorted_note_stream(vec![
2847 (Amount::from_sats(1), 3),
2848 (Amount::from_sats(5), 3),
2849 (Amount::from_sats(20), 2),
2850 ]);
2851 assert_eq!(
2852 select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
2853 .await
2854 .unwrap(),
2855 notes(vec![(Amount::from_sats(20), 2)])
2856 );
2857 }
2858
2859 #[test_log::test(tokio::test)]
2860 async fn select_notes_returns_error_if_amount_is_too_large() {
2861 let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
2862 let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
2863 .await
2864 .unwrap_err();
2865 assert_eq!(error.total_amount, Amount::from_sats(10));
2866 }
2867
2868 fn reverse_sorted_note_stream(
2869 notes: Vec<(Amount, usize)>,
2870 ) -> impl futures::Stream<Item = (Amount, String)> {
2871 futures::stream::iter(
2872 notes
2873 .into_iter()
2874 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2876 .sorted()
2877 .rev(),
2878 )
2879 }
2880
2881 fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
2882 notes
2883 .into_iter()
2884 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2885 .collect()
2886 }
2887
2888 #[test]
2889 fn decoding_empty_oob_notes_fails() {
2890 let empty_oob_notes =
2891 OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
2892 let oob_notes_string = empty_oob_notes.to_string();
2893
2894 let res = oob_notes_string.parse::<OOBNotes>();
2895
2896 assert!(res.is_err(), "An empty OOB notes string should not parse");
2897 }
2898
2899 fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
2900 where
2901 T: FromStr + Display + crate::Encodable + crate::Decodable,
2902 <T as FromStr>::Err: std::fmt::Debug,
2903 F: Fn(T),
2904 {
2905 let data_parsed = data.to_string().parse().expect("Deserialization failed");
2906
2907 assertions(data_parsed);
2908
2909 let data_parsed = crate::base32::encode_prefixed(FEDIMINT_PREFIX, &data)
2910 .parse()
2911 .expect("Deserialization failed");
2912
2913 assertions(data_parsed);
2914
2915 assertions(data);
2916 }
2917
2918 #[test]
2919 fn notes_encode_decode() {
2920 let federation_id_1 =
2921 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
2922 let federation_id_prefix_1 = federation_id_1.to_prefix();
2923 let federation_id_2 =
2924 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
2925 let federation_id_prefix_2 = federation_id_2.to_prefix();
2926
2927 let notes = vec![(
2928 Amount::from_sats(1),
2929 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
2930 )]
2931 .into_iter()
2932 .collect::<TieredMulti<_>>();
2933
2934 let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
2936 test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
2937 assert_eq!(oob_notes.notes(), ¬es);
2938 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2939 assert_eq!(oob_notes.federation_invite(), None);
2940 });
2941
2942 let invite = InviteCode::new(
2944 "wss://foo.bar".parse().unwrap(),
2945 PeerId::from(0),
2946 federation_id_1,
2947 None,
2948 );
2949 let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
2950 test_roundtrip_serialize_str(notes_invite, |oob_notes| {
2951 assert_eq!(oob_notes.notes(), ¬es);
2952 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2953 assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
2954 });
2955
2956 let notes_no_prefix = OOBNotes(vec![
2959 OOBNotesPart::Notes(notes.clone()),
2960 OOBNotesPart::Invite {
2961 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2962 federation_id: federation_id_1,
2963 },
2964 ]);
2965 test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
2966 assert_eq!(oob_notes.notes(), ¬es);
2967 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2968 });
2969
2970 let notes_inconsistent = OOBNotes(vec![
2972 OOBNotesPart::Notes(notes),
2973 OOBNotesPart::Invite {
2974 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2975 federation_id: federation_id_1,
2976 },
2977 OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
2978 ]);
2979 let notes_inconsistent_str = notes_inconsistent.to_string();
2980 assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
2981 }
2982
2983 #[test]
2984 fn spendable_note_undecoded_sanity() {
2985 #[allow(clippy::single_element_loop)]
2987 for note_hex in [
2988 "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
2989 ] {
2990 let note =
2991 SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
2992 let note_undecoded =
2993 SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
2994 .unwrap()
2995 .decode()
2996 .unwrap();
2997 assert_eq!(note, note_undecoded,);
2998 assert_eq!(
2999 serde_json::to_string(¬e).unwrap(),
3000 serde_json::to_string(¬e_undecoded).unwrap(),
3001 );
3002 }
3003 }
3004
3005 #[test]
3006 fn reissuance_meta_compatibility_02_03() {
3007 let dummy_outpoint = OutPoint {
3008 txid: TransactionId::all_zeros(),
3009 out_idx: 0,
3010 };
3011
3012 let old_meta_json = json!({
3013 "reissuance": {
3014 "out_point": dummy_outpoint
3015 }
3016 });
3017
3018 let old_meta: MintOperationMetaVariant =
3019 serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
3020 assert_eq!(
3021 old_meta,
3022 MintOperationMetaVariant::Reissuance {
3023 legacy_out_point: Some(dummy_outpoint),
3024 txid: None,
3025 out_point_indices: vec![],
3026 }
3027 );
3028
3029 let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
3030 legacy_out_point: None,
3031 txid: Some(dummy_outpoint.txid),
3032 out_point_indices: vec![0],
3033 })
3034 .expect("serializing always works");
3035 assert_eq!(
3036 new_meta_json,
3037 json!({
3038 "reissuance": {
3039 "txid": dummy_outpoint.txid,
3040 "out_point_indices": [dummy_outpoint.out_idx],
3041 }
3042 })
3043 );
3044 }
3045}