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 await_output_finalized(
1164 &self,
1165 operation_id: OperationId,
1166 out_point: OutPoint,
1167 ) -> anyhow::Result<()> {
1168 let stream = self
1169 .notifier
1170 .subscribe(operation_id)
1171 .await
1172 .filter_map(|state| async {
1173 let MintClientStateMachines::Output(state) = state else {
1174 return None;
1175 };
1176
1177 if state.common.txid() != out_point.txid
1178 || !state
1179 .common
1180 .out_point_range
1181 .out_idx_iter()
1182 .contains(&out_point.out_idx)
1183 {
1184 return None;
1185 }
1186
1187 match state.state {
1188 MintOutputStates::Succeeded(_) => Some(Ok(())),
1189 MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
1190 MintOutputStates::Failed(failed) => Some(Err(anyhow!(
1191 "Failed to finalize transaction: {}",
1192 failed.error
1193 ))),
1194 MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1195 }
1196 });
1197 pin_mut!(stream);
1198
1199 stream.next_or_pending().await
1200 }
1201
1202 pub async fn consolidate_notes(
1209 &self,
1210 dbtx: &mut DatabaseTransaction<'_>,
1211 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1212 const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1215 const MIN_NOTES_PER_TIER: usize = 4;
1217 const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1220 #[allow(clippy::assertions_on_constants)]
1222 {
1223 assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1224 }
1225
1226 let counts = self.get_note_counts_by_denomination(dbtx).await;
1227
1228 let should_consolidate = counts
1229 .iter()
1230 .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1231
1232 if !should_consolidate {
1233 return Ok(vec![]);
1234 }
1235
1236 let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1237
1238 let excessive_counts: TieredCounts = counts
1239 .iter()
1240 .map(|(amount, count)| {
1241 let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1242
1243 max_count -= take;
1244 (amount, take)
1245 })
1246 .collect();
1247
1248 let (selected_notes, unavailable) = self
1249 .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1250 .await;
1251
1252 debug_assert!(
1253 unavailable.is_empty(),
1254 "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1255 );
1256
1257 if !selected_notes.is_empty() {
1258 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");
1259 }
1260
1261 let mut selected_notes_decoded = vec![];
1262 for (amount, note) in selected_notes.iter_items() {
1263 let spendable_note_decoded = note.decode()?;
1264 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1265 Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1266 .await;
1267 selected_notes_decoded.push((amount, spendable_note_decoded));
1268 }
1269
1270 let sender = self.balance_update_sender.clone();
1271 dbtx.on_commit(move || sender.send_replace(()));
1272
1273 self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1274 }
1275
1276 #[allow(clippy::type_complexity)]
1278 pub fn create_input_from_notes(
1279 &self,
1280 notes: TieredMulti<SpendableNote>,
1281 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1282 let mut inputs_and_notes = Vec::new();
1283
1284 for (amount, spendable_note) in notes.into_iter_items() {
1285 let key = self
1286 .cfg
1287 .tbs_pks
1288 .get(amount)
1289 .ok_or(anyhow!("Invalid amount tier: {amount}"))?;
1290
1291 let note = spendable_note.note();
1292
1293 if !note.verify(*key) {
1294 bail!("Invalid note");
1295 }
1296
1297 inputs_and_notes.push((
1298 ClientInput {
1299 input: MintInput::new_v0(amount, note),
1300 keys: vec![spendable_note.spend_key],
1301 amounts: Amounts::new_bitcoin(amount),
1302 },
1303 spendable_note,
1304 ));
1305 }
1306
1307 Ok(inputs_and_notes)
1308 }
1309
1310 async fn spend_notes_oob(
1311 &self,
1312 dbtx: &mut DatabaseTransaction<'_>,
1313 notes_selector: &impl NotesSelector,
1314 amount: Amount,
1315 try_cancel_after: Duration,
1316 ) -> anyhow::Result<(
1317 OperationId,
1318 Vec<MintClientStateMachines>,
1319 TieredMulti<SpendableNote>,
1320 )> {
1321 ensure!(
1322 amount > Amount::ZERO,
1323 "zero-amount out-of-band spends are not supported"
1324 );
1325
1326 let selected_notes =
1327 Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1328
1329 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1330
1331 for (amount, note) in selected_notes.iter_items() {
1332 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1333 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1334 }
1335
1336 let sender = self.balance_update_sender.clone();
1337 dbtx.on_commit(move || sender.send_replace(()));
1338
1339 let state_machines = vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1340 operation_id,
1341 state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1342 spendable_notes: selected_notes.clone().into_iter_items().collect(),
1343 timeout: fedimint_core::time::now() + try_cancel_after,
1344 }),
1345 })];
1346
1347 Ok((operation_id, state_machines, selected_notes))
1348 }
1349
1350 pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1351 Box::pin(
1352 self.notifier
1353 .subscribe(operation_id)
1354 .await
1355 .filter_map(|state| async {
1356 let MintClientStateMachines::OOB(state) = state else {
1357 return None;
1358 };
1359
1360 match state.state {
1361 MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1362 user_triggered: false,
1363 transaction_ids: vec![refund.refund_txid],
1364 }),
1365 MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1366 user_triggered: true,
1367 transaction_ids: vec![refund.refund_txid],
1368 }),
1369 MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1370 user_triggered: true,
1371 transaction_ids: vec![refund.refund_txid],
1372 }),
1373 MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1374 }
1375 }),
1376 )
1377 .next_or_pending()
1378 .await
1379 }
1380
1381 async fn select_notes(
1383 dbtx: &mut DatabaseTransaction<'_>,
1384 notes_selector: &impl NotesSelector,
1385 requested_amount: Amount,
1386 fee_consensus: FeeConsensus,
1387 ) -> anyhow::Result<TieredMulti<SpendableNote>> {
1388 let note_stream = dbtx
1389 .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1390 .await
1391 .map(|(key, note)| (key.amount, note));
1392
1393 notes_selector
1394 .select_notes(note_stream, requested_amount, fee_consensus)
1395 .await?
1396 .into_iter_items()
1397 .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1398 .collect::<anyhow::Result<TieredMulti<_>>>()
1399 }
1400
1401 async fn get_all_spendable_notes(
1402 dbtx: &mut DatabaseTransaction<'_>,
1403 ) -> TieredMulti<SpendableNoteUndecoded> {
1404 (dbtx
1405 .find_by_prefix(&NoteKeyPrefix)
1406 .await
1407 .map(|(key, note)| (key.amount, note))
1408 .collect::<Vec<_>>()
1409 .await)
1410 .into_iter()
1411 .collect()
1412 }
1413
1414 async fn get_next_note_index(
1415 &self,
1416 dbtx: &mut DatabaseTransaction<'_>,
1417 amount: Amount,
1418 ) -> NoteIndex {
1419 NoteIndex(
1420 dbtx.get_value(&NextECashNoteIndexKey(amount))
1421 .await
1422 .unwrap_or(0),
1423 )
1424 }
1425
1426 pub fn new_note_secret_static(
1442 secret: &DerivableSecret,
1443 amount: Amount,
1444 note_idx: NoteIndex,
1445 ) -> DerivableSecret {
1446 assert_eq!(secret.level(), 2);
1447 debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1448 secret
1449 .child_key(MINT_E_CASH_TYPE_CHILD_ID) .child_key(ChildId(note_idx.as_u64()))
1451 .child_key(ChildId(amount.msats))
1452 }
1453
1454 async fn new_note_secret(
1458 &self,
1459 amount: Amount,
1460 dbtx: &mut DatabaseTransaction<'_>,
1461 ) -> DerivableSecret {
1462 let new_idx = self.get_next_note_index(dbtx, amount).await;
1463 dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1464 .await;
1465 Self::new_note_secret_static(&self.secret, amount, new_idx)
1466 }
1467
1468 pub async fn new_ecash_note(
1469 &self,
1470 amount: Amount,
1471 dbtx: &mut DatabaseTransaction<'_>,
1472 ) -> (NoteIssuanceRequest, BlindNonce) {
1473 let secret = self.new_note_secret(amount, dbtx).await;
1474 NoteIssuanceRequest::new(&self.secp, &secret)
1475 }
1476
1477 pub async fn reissue_external_notes<M: Serialize + Send>(
1482 &self,
1483 oob_notes: OOBNotes,
1484 extra_meta: M,
1485 ) -> anyhow::Result<OperationId> {
1486 let notes = oob_notes.notes().clone();
1487 let federation_id_prefix = oob_notes.federation_id_prefix();
1488
1489 ensure!(
1490 notes.total_amount() > Amount::ZERO,
1491 "Reissuing zero-amount e-cash isn't supported"
1492 );
1493
1494 if federation_id_prefix != self.federation_id.to_prefix() {
1495 bail!(ReissueExternalNotesError::WrongFederationId);
1496 }
1497
1498 let operation_id = OperationId(
1499 notes
1500 .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
1501 .to_byte_array(),
1502 );
1503
1504 let amount = notes.total_amount();
1505 let mint_inputs = self.create_input_from_notes(notes)?;
1506
1507 let tx = TransactionBuilder::new().with_inputs(
1508 self.client_ctx
1509 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
1510 );
1511
1512 let extra_meta = serde_json::to_value(extra_meta)
1513 .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
1514 let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
1515 variant: MintOperationMetaVariant::Reissuance {
1516 legacy_out_point: None,
1517 txid: Some(change_range.txid()),
1518 out_point_indices: change_range
1519 .into_iter()
1520 .map(|out_point| out_point.out_idx)
1521 .collect(),
1522 },
1523 amount,
1524 extra_meta: extra_meta.clone(),
1525 };
1526
1527 self.client_ctx
1528 .finalize_and_submit_transaction(
1529 operation_id,
1530 MintCommonInit::KIND.as_str(),
1531 operation_meta_gen,
1532 tx,
1533 )
1534 .await
1535 .context(ReissueExternalNotesError::AlreadyReissued)?;
1536
1537 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1538
1539 self.client_ctx
1540 .log_event(&mut dbtx, OOBNotesReissued { amount })
1541 .await;
1542
1543 self.client_ctx
1544 .log_event(
1545 &mut dbtx,
1546 ReceivePaymentEvent {
1547 operation_id,
1548 amount,
1549 },
1550 )
1551 .await;
1552
1553 dbtx.commit_tx().await;
1554
1555 Ok(operation_id)
1556 }
1557
1558 pub async fn subscribe_reissue_external_notes(
1561 &self,
1562 operation_id: OperationId,
1563 ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
1564 let operation = self.mint_operation(operation_id).await?;
1565 let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
1566 MintOperationMetaVariant::Reissuance {
1567 legacy_out_point,
1568 txid,
1569 out_point_indices,
1570 } => {
1571 let txid = txid
1574 .or(legacy_out_point.map(|out_point| out_point.txid))
1575 .context("Empty reissuance not permitted, this should never happen")?;
1576
1577 let out_points = out_point_indices
1578 .into_iter()
1579 .map(|out_idx| OutPoint { txid, out_idx })
1580 .chain(legacy_out_point)
1581 .collect::<Vec<_>>();
1582
1583 (txid, out_points)
1584 }
1585 MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"),
1586 MintOperationMetaVariant::Recovery { .. } => unimplemented!(),
1587 };
1588
1589 let client_ctx = self.client_ctx.clone();
1590
1591 Ok(self.client_ctx.outcome_or_updates(operation, operation_id, move || {
1592 stream! {
1593 yield ReissueExternalNotesState::Created;
1594
1595 match client_ctx
1596 .transaction_updates(operation_id)
1597 .await
1598 .await_tx_accepted(txid)
1599 .await
1600 {
1601 Ok(()) => {
1602 yield ReissueExternalNotesState::Issuing;
1603 }
1604 Err(e) => {
1605 yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
1606 return;
1607 }
1608 }
1609
1610 for out_point in out_points {
1611 if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
1612 yield ReissueExternalNotesState::Failed(e.to_string());
1613 return;
1614 }
1615 }
1616 yield ReissueExternalNotesState::Done;
1617 }}
1618 ))
1619 }
1620
1621 #[deprecated(
1633 since = "0.5.0",
1634 note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
1635 )]
1636 pub async fn spend_notes<M: Serialize + Send>(
1637 &self,
1638 min_amount: Amount,
1639 try_cancel_after: Duration,
1640 include_invite: bool,
1641 extra_meta: M,
1642 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1643 self.spend_notes_with_selector(
1644 &SelectNotesWithAtleastAmount,
1645 min_amount,
1646 try_cancel_after,
1647 include_invite,
1648 extra_meta,
1649 )
1650 .await
1651 }
1652
1653 pub async fn spend_notes_with_selector<M: Serialize + Send>(
1669 &self,
1670 notes_selector: &impl NotesSelector,
1671 requested_amount: Amount,
1672 try_cancel_after: Duration,
1673 include_invite: bool,
1674 extra_meta: M,
1675 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1676 let federation_id_prefix = self.federation_id.to_prefix();
1677 let extra_meta = serde_json::to_value(extra_meta)
1678 .expect("MintClientModule::spend_notes extra_meta is serializable");
1679
1680 self.client_ctx
1681 .module_db()
1682 .autocommit(
1683 |dbtx, _| {
1684 let extra_meta = extra_meta.clone();
1685 Box::pin(async {
1686 let (operation_id, states, notes) = self
1687 .spend_notes_oob(
1688 dbtx,
1689 notes_selector,
1690 requested_amount,
1691 try_cancel_after,
1692 )
1693 .await?;
1694
1695 let oob_notes = if include_invite {
1696 OOBNotes::new_with_invite(
1697 notes,
1698 &self.client_ctx.get_invite_code().await,
1699 )
1700 } else {
1701 OOBNotes::new(federation_id_prefix, notes)
1702 };
1703
1704 self.client_ctx
1705 .add_state_machines_dbtx(
1706 dbtx,
1707 self.client_ctx.map_dyn(states).collect(),
1708 )
1709 .await?;
1710 self.client_ctx
1711 .add_operation_log_entry_dbtx(
1712 dbtx,
1713 operation_id,
1714 MintCommonInit::KIND.as_str(),
1715 MintOperationMeta {
1716 variant: MintOperationMetaVariant::SpendOOB {
1717 requested_amount,
1718 oob_notes: oob_notes.clone(),
1719 },
1720 amount: oob_notes.total_amount(),
1721 extra_meta,
1722 },
1723 )
1724 .await;
1725 self.client_ctx
1726 .log_event(
1727 dbtx,
1728 OOBNotesSpent {
1729 requested_amount,
1730 spent_amount: oob_notes.total_amount(),
1731 timeout: try_cancel_after,
1732 include_invite,
1733 },
1734 )
1735 .await;
1736
1737 self.client_ctx
1738 .log_event(
1739 dbtx,
1740 SendPaymentEvent {
1741 operation_id,
1742 amount: oob_notes.total_amount(),
1743 oob_notes: oob_notes.to_string(),
1744 },
1745 )
1746 .await;
1747
1748 Ok((operation_id, oob_notes))
1749 })
1750 },
1751 Some(100),
1752 )
1753 .await
1754 .map_err(|e| match e {
1755 AutocommitError::ClosureError { error, .. } => error,
1756 AutocommitError::CommitFailed { last_error, .. } => {
1757 anyhow!("Commit to DB failed: {last_error}")
1758 }
1759 })
1760 }
1761
1762 pub async fn send_oob_notes<M: Serialize + Send>(
1788 &self,
1789 amount: Amount,
1790 extra_meta: M,
1791 ) -> anyhow::Result<OOBNotes> {
1792 let amount = Amount::from_msats(amount.msats.div_ceil(512) * 512);
1794
1795 let extra_meta = serde_json::to_value(extra_meta)
1796 .expect("MintClientModule::send_oob_notes extra_meta is serializable");
1797
1798 let oob_notes: Option<OOBNotes> = self
1800 .client_ctx
1801 .module_db()
1802 .autocommit(
1803 |dbtx, _| {
1804 let extra_meta = extra_meta.clone();
1805 Box::pin(async {
1806 self.try_spend_exact_notes_dbtx(
1807 dbtx,
1808 amount,
1809 self.federation_id,
1810 extra_meta,
1811 )
1812 .await
1813 .map(Ok::<OOBNotes, anyhow::Error>)
1814 .transpose()
1815 })
1816 },
1817 Some(100),
1818 )
1819 .await
1820 .expect("Failed to commit dbtx after 100 retries");
1821
1822 if let Some(oob_notes) = oob_notes {
1823 return Ok(oob_notes);
1824 }
1825
1826 self.client_ctx
1828 .global_api()
1829 .session_count()
1830 .await
1831 .context("Cannot reach federation to reissue notes")?;
1832
1833 let operation_id = OperationId::new_random();
1834
1835 let output_bundle = self
1837 .create_output(
1838 &mut self.client_ctx.module_db().begin_transaction_nc().await,
1839 operation_id,
1840 1, amount,
1842 )
1843 .await;
1844
1845 let combined_bundle = ClientOutputBundle::new(
1847 output_bundle.outputs().to_vec(),
1848 output_bundle.sms().to_vec(),
1849 );
1850
1851 let outputs = self.client_ctx.make_client_outputs(combined_bundle);
1852
1853 let em_clone = extra_meta.clone();
1854
1855 let out_point_range = self
1857 .client_ctx
1858 .finalize_and_submit_transaction(
1859 operation_id,
1860 MintCommonInit::KIND.as_str(),
1861 move |change_range: OutPointRange| MintOperationMeta {
1862 variant: MintOperationMetaVariant::Reissuance {
1863 legacy_out_point: None,
1864 txid: Some(change_range.txid()),
1865 out_point_indices: change_range
1866 .into_iter()
1867 .map(|out_point| out_point.out_idx)
1868 .collect(),
1869 },
1870 amount,
1871 extra_meta: em_clone.clone(),
1872 },
1873 TransactionBuilder::new().with_outputs(outputs),
1874 )
1875 .await
1876 .context("Failed to submit reissuance transaction")?;
1877
1878 self.client_ctx
1880 .await_primary_module_outputs(operation_id, out_point_range.into_iter().collect())
1881 .await
1882 .context("Failed to await output finalization")?;
1883
1884 Box::pin(self.send_oob_notes(amount, extra_meta)).await
1886 }
1887
1888 async fn try_spend_exact_notes_dbtx(
1891 &self,
1892 dbtx: &mut DatabaseTransaction<'_>,
1893 amount: Amount,
1894 federation_id: FederationId,
1895 extra_meta: serde_json::Value,
1896 ) -> Option<OOBNotes> {
1897 let selected_notes = Self::select_notes(
1898 dbtx,
1899 &SelectNotesWithExactAmount,
1900 amount,
1901 FeeConsensus::zero(),
1902 )
1903 .await
1904 .ok()?;
1905
1906 for (note_amount, note) in selected_notes.iter_items() {
1908 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, note_amount, note)
1909 .await;
1910 }
1911
1912 let sender = self.balance_update_sender.clone();
1913 dbtx.on_commit(move || sender.send_replace(()));
1914
1915 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1916
1917 let oob_notes = OOBNotes::new(federation_id.to_prefix(), selected_notes);
1918
1919 self.client_ctx
1921 .add_operation_log_entry_dbtx(
1922 dbtx,
1923 operation_id,
1924 MintCommonInit::KIND.as_str(),
1925 MintOperationMeta {
1926 variant: MintOperationMetaVariant::SpendOOB {
1927 requested_amount: amount,
1928 oob_notes: oob_notes.clone(),
1929 },
1930 amount: oob_notes.total_amount(),
1931 extra_meta,
1932 },
1933 )
1934 .await;
1935
1936 self.client_ctx
1937 .log_event(
1938 dbtx,
1939 SendPaymentEvent {
1940 operation_id,
1941 amount: oob_notes.total_amount(),
1942 oob_notes: oob_notes.to_string(),
1943 },
1944 )
1945 .await;
1946
1947 Some(oob_notes)
1948 }
1949
1950 pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> {
1956 let federation_id_prefix = oob_notes.federation_id_prefix();
1957 let notes = oob_notes.notes().clone();
1958
1959 if federation_id_prefix != self.federation_id.to_prefix() {
1960 bail!("Federation ID does not match");
1961 }
1962
1963 let tbs_pks = &self.cfg.tbs_pks;
1964
1965 for (idx, (amt, snote)) in notes.iter_items().enumerate() {
1966 let key = tbs_pks
1967 .get(amt)
1968 .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
1969
1970 let note = snote.note();
1971 if !note.verify(*key) {
1972 bail!("Note {idx} has an invalid federation signature");
1973 }
1974
1975 let expected_nonce = Nonce(snote.spend_key.public_key());
1976 if note.nonce != expected_nonce {
1977 bail!("Note {idx} cannot be spent using the supplied spend key");
1978 }
1979 }
1980
1981 Ok(notes.total_amount())
1982 }
1983
1984 pub async fn check_note_spent(&self, oob_notes: &OOBNotes) -> anyhow::Result<bool> {
1990 use crate::api::MintFederationApi;
1991
1992 let api_client = self.client_ctx.module_api();
1993 let any_spent = try_join_all(oob_notes.notes().iter().flat_map(|(_, notes)| {
1994 notes
1995 .iter()
1996 .map(|note| api_client.check_note_spent(note.nonce()))
1997 }))
1998 .await?
1999 .into_iter()
2000 .any(|spent| spent);
2001
2002 Ok(any_spent)
2003 }
2004
2005 pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
2010 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
2011 dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
2012 .await;
2013 if let Err(e) = dbtx.commit_tx_result().await {
2014 warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
2015 }
2016 }
2017
2018 pub async fn subscribe_spend_notes(
2021 &self,
2022 operation_id: OperationId,
2023 ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
2024 let operation = self.mint_operation(operation_id).await?;
2025 if !matches!(
2026 operation.meta::<MintOperationMeta>().variant,
2027 MintOperationMetaVariant::SpendOOB { .. }
2028 ) {
2029 bail!("Operation is not a out-of-band spend");
2030 }
2031
2032 let client_ctx = self.client_ctx.clone();
2033
2034 Ok(self
2035 .client_ctx
2036 .outcome_or_updates(operation, operation_id, move || {
2037 stream! {
2038 yield SpendOOBState::Created;
2039
2040 let self_ref = client_ctx.self_ref();
2041
2042 let refund = self_ref
2043 .await_spend_oob_refund(operation_id)
2044 .await;
2045
2046 if refund.user_triggered {
2047 yield SpendOOBState::UserCanceledProcessing;
2048 }
2049
2050 let mut success = true;
2051
2052 for txid in refund.transaction_ids {
2053 debug!(
2054 target: LOG_CLIENT_MODULE_MINT,
2055 %txid,
2056 operation_id=%operation_id.fmt_short(),
2057 "Waiting for oob refund txid"
2058 );
2059 if client_ctx
2060 .transaction_updates(operation_id)
2061 .await
2062 .await_tx_accepted(txid)
2063 .await.is_err() {
2064 success = false;
2065 }
2066 }
2067
2068 debug!(
2069 target: LOG_CLIENT_MODULE_MINT,
2070 operation_id=%operation_id.fmt_short(),
2071 %success,
2072 "Done waiting for all refund oob txids"
2073 );
2074
2075 match (refund.user_triggered, success) {
2076 (true, true) => {
2077 yield SpendOOBState::UserCanceledSuccess;
2078 },
2079 (true, false) => {
2080 yield SpendOOBState::UserCanceledFailure;
2081 },
2082 (false, true) => {
2083 yield SpendOOBState::Refunded;
2084 },
2085 (false, false) => {
2086 yield SpendOOBState::Success;
2087 }
2088 }
2089 }
2090 }))
2091 }
2092
2093 async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
2094 let operation = self.client_ctx.get_operation(operation_id).await?;
2095
2096 if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
2097 bail!("Operation is not a mint operation");
2098 }
2099
2100 Ok(operation)
2101 }
2102
2103 async fn delete_spendable_note(
2104 client_ctx: &ClientContext<MintClientModule>,
2105 dbtx: &mut DatabaseTransaction<'_>,
2106 amount: Amount,
2107 note: &SpendableNote,
2108 ) {
2109 client_ctx
2110 .log_event(
2111 dbtx,
2112 NoteSpent {
2113 nonce: note.nonce(),
2114 },
2115 )
2116 .await;
2117 dbtx.remove_entry(&NoteKey {
2118 amount,
2119 nonce: note.nonce(),
2120 })
2121 .await
2122 .expect("Must deleted existing spendable note");
2123 }
2124
2125 pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> {
2126 let db = self.client_ctx.module_db().clone();
2127
2128 Ok(db
2129 .autocommit(
2130 |dbtx, _| {
2131 Box::pin(async {
2132 Ok::<DerivableSecret, anyhow::Error>(
2133 self.new_note_secret(amount, dbtx).await,
2134 )
2135 })
2136 },
2137 None,
2138 )
2139 .await?)
2140 }
2141
2142 pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
2145 self.client_ctx
2146 .module_db()
2147 .begin_transaction_nc()
2148 .await
2149 .get_value(&ReusedNoteIndices)
2150 .await
2151 .unwrap_or_default()
2152 .into_iter()
2153 .map(|(amount, note_idx)| {
2154 let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
2155 let (request, blind_nonce) =
2156 NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
2157 (amount, request, blind_nonce)
2158 })
2159 .collect()
2160 }
2161}
2162
2163pub fn spendable_notes_to_operation_id(
2164 spendable_selected_notes: &TieredMulti<SpendableNote>,
2165) -> OperationId {
2166 OperationId(
2167 spendable_selected_notes
2168 .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
2169 .to_byte_array(),
2170 )
2171}
2172
2173#[derive(Debug, Serialize, Deserialize, Clone)]
2174pub struct SpendOOBRefund {
2175 pub user_triggered: bool,
2176 pub transaction_ids: Vec<TransactionId>,
2177}
2178
2179#[apply(async_trait_maybe_send!)]
2182pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
2183 async fn select_notes(
2186 &self,
2187 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2189 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2190 requested_amount: Amount,
2191 fee_consensus: FeeConsensus,
2192 ) -> anyhow::Result<TieredMulti<Note>>;
2193}
2194
2195pub struct SelectNotesWithAtleastAmount;
2201
2202#[apply(async_trait_maybe_send!)]
2203impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
2204 async fn select_notes(
2205 &self,
2206 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2207 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2208 requested_amount: Amount,
2209 fee_consensus: FeeConsensus,
2210 ) -> anyhow::Result<TieredMulti<Note>> {
2211 Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
2212 }
2213}
2214
2215pub struct SelectNotesWithExactAmount;
2219
2220#[apply(async_trait_maybe_send!)]
2221impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
2222 async fn select_notes(
2223 &self,
2224 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2225 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2226 requested_amount: Amount,
2227 fee_consensus: FeeConsensus,
2228 ) -> anyhow::Result<TieredMulti<Note>> {
2229 let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
2230
2231 if notes.total_amount() != requested_amount {
2232 bail!(
2233 "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}",
2234 requested_amount,
2235 notes.total_amount()
2236 );
2237 }
2238
2239 Ok(notes)
2240 }
2241}
2242
2243async fn select_notes_from_stream<Note>(
2249 stream: impl futures::Stream<Item = (Amount, Note)>,
2250 requested_amount: Amount,
2251 fee_consensus: FeeConsensus,
2252) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
2253 if requested_amount == Amount::ZERO {
2254 return Ok(TieredMulti::default());
2255 }
2256 let mut stream = Box::pin(stream);
2257 let mut selected = vec![];
2258 let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
2263 let mut pending_amount = requested_amount;
2264 let mut previous_amount: Option<Amount> = None; loop {
2266 if let Some((note_amount, note)) = stream.next().await {
2267 assert!(
2268 previous_amount.is_none_or(|previous| previous >= note_amount),
2269 "notes are not sorted in descending order"
2270 );
2271 previous_amount = Some(note_amount);
2272
2273 if note_amount <= fee_consensus.fee(note_amount) {
2274 continue;
2275 }
2276
2277 match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2278 Ordering::Less => {
2279 pending_amount += fee_consensus.fee(note_amount);
2281 pending_amount -= note_amount;
2282 selected.push((note_amount, note));
2283 }
2284 Ordering::Greater => {
2285 last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2289 }
2290 Ordering::Equal => {
2291 selected.push((note_amount, note));
2293
2294 let notes: TieredMulti<Note> = selected.into_iter().collect();
2295
2296 assert!(
2297 notes.total_amount().msats
2298 >= requested_amount.msats
2299 + notes
2300 .iter()
2301 .map(|note| fee_consensus.fee(note.0))
2302 .sum::<Amount>()
2303 .msats
2304 );
2305
2306 return Ok(notes);
2307 }
2308 }
2309 } else {
2310 assert!(pending_amount > Amount::ZERO);
2311 if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2312 selected.truncate(checkpoint);
2315 selected.push((big_note_amount, big_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);
2332 }
2333
2334 let total_amount = requested_amount.saturating_sub(pending_amount);
2335 return Err(InsufficientBalanceError {
2337 requested_amount,
2338 total_amount,
2339 });
2340 }
2341 }
2342}
2343
2344#[derive(Debug, Clone, Error)]
2345pub struct InsufficientBalanceError {
2346 pub requested_amount: Amount,
2347 pub total_amount: Amount,
2348}
2349
2350impl std::fmt::Display for InsufficientBalanceError {
2351 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2352 write!(
2353 f,
2354 "Insufficient balance: requested {} but only {} available",
2355 self.requested_amount, self.total_amount
2356 )
2357 }
2358}
2359
2360#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2362enum MintRestoreStates {
2363 #[encodable_default]
2364 Default { variant: u64, bytes: Vec<u8> },
2365}
2366
2367#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2369pub struct MintRestoreStateMachine {
2370 operation_id: OperationId,
2371 state: MintRestoreStates,
2372}
2373
2374#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2375pub enum MintClientStateMachines {
2376 Output(MintOutputStateMachine),
2377 Input(MintInputStateMachine),
2378 OOB(MintOOBStateMachine),
2379 Restore(MintRestoreStateMachine),
2381}
2382
2383impl IntoDynInstance for MintClientStateMachines {
2384 type DynType = DynState;
2385
2386 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2387 DynState::from_typed(instance_id, self)
2388 }
2389}
2390
2391impl State for MintClientStateMachines {
2392 type ModuleContext = MintClientContext;
2393
2394 fn transitions(
2395 &self,
2396 context: &Self::ModuleContext,
2397 global_context: &DynGlobalClientContext,
2398 ) -> Vec<StateTransition<Self>> {
2399 match self {
2400 MintClientStateMachines::Output(issuance_state) => {
2401 sm_enum_variant_translation!(
2402 issuance_state.transitions(context, global_context),
2403 MintClientStateMachines::Output
2404 )
2405 }
2406 MintClientStateMachines::Input(redemption_state) => {
2407 sm_enum_variant_translation!(
2408 redemption_state.transitions(context, global_context),
2409 MintClientStateMachines::Input
2410 )
2411 }
2412 MintClientStateMachines::OOB(oob_state) => {
2413 sm_enum_variant_translation!(
2414 oob_state.transitions(context, global_context),
2415 MintClientStateMachines::OOB
2416 )
2417 }
2418 MintClientStateMachines::Restore(_) => {
2419 sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
2420 }
2421 }
2422 }
2423
2424 fn operation_id(&self) -> OperationId {
2425 match self {
2426 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
2427 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
2428 MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
2429 MintClientStateMachines::Restore(r) => r.operation_id,
2430 }
2431 }
2432}
2433
2434#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
2437pub struct SpendableNote {
2438 pub signature: tbs::Signature,
2439 pub spend_key: Keypair,
2440}
2441
2442impl fmt::Debug for SpendableNote {
2443 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2444 f.debug_struct("SpendableNote")
2445 .field("nonce", &self.nonce())
2446 .field("signature", &self.signature)
2447 .field("spend_key", &self.spend_key)
2448 .finish()
2449 }
2450}
2451impl fmt::Display for SpendableNote {
2452 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2453 self.nonce().fmt(f)
2454 }
2455}
2456
2457impl SpendableNote {
2458 pub fn nonce(&self) -> Nonce {
2459 Nonce(self.spend_key.public_key())
2460 }
2461
2462 fn note(&self) -> Note {
2463 Note {
2464 nonce: self.nonce(),
2465 signature: self.signature,
2466 }
2467 }
2468
2469 pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
2470 SpendableNoteUndecoded {
2471 signature: self
2472 .signature
2473 .consensus_encode_to_vec()
2474 .try_into()
2475 .expect("Encoded size always correct"),
2476 spend_key: self.spend_key,
2477 }
2478 }
2479}
2480
2481#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
2493pub struct SpendableNoteUndecoded {
2494 #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
2497 pub signature: [u8; 48],
2498 pub spend_key: Keypair,
2499}
2500
2501impl fmt::Display for SpendableNoteUndecoded {
2502 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2503 self.nonce().fmt(f)
2504 }
2505}
2506
2507impl fmt::Debug for SpendableNoteUndecoded {
2508 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2509 f.debug_struct("SpendableNote")
2510 .field("nonce", &self.nonce())
2511 .field("signature", &"[raw]")
2512 .field("spend_key", &self.spend_key)
2513 .finish()
2514 }
2515}
2516
2517impl SpendableNoteUndecoded {
2518 fn nonce(&self) -> Nonce {
2519 Nonce(self.spend_key.public_key())
2520 }
2521
2522 pub fn decode(self) -> anyhow::Result<SpendableNote> {
2523 Ok(SpendableNote {
2524 signature: Decodable::consensus_decode_partial_from_finite_reader(
2525 &mut self.signature.as_slice(),
2526 &ModuleRegistry::default(),
2527 )?,
2528 spend_key: self.spend_key,
2529 })
2530 }
2531}
2532
2533#[derive(
2539 Copy,
2540 Clone,
2541 Debug,
2542 Serialize,
2543 Deserialize,
2544 PartialEq,
2545 Eq,
2546 Encodable,
2547 Decodable,
2548 Default,
2549 PartialOrd,
2550 Ord,
2551)]
2552pub struct NoteIndex(u64);
2553
2554impl NoteIndex {
2555 pub fn next(self) -> Self {
2556 Self(self.0 + 1)
2557 }
2558
2559 fn prev(self) -> Option<Self> {
2560 self.0.checked_sub(0).map(Self)
2561 }
2562
2563 pub fn as_u64(self) -> u64 {
2564 self.0
2565 }
2566
2567 #[allow(unused)]
2571 pub fn from_u64(v: u64) -> Self {
2572 Self(v)
2573 }
2574
2575 pub fn advance(&mut self) {
2576 *self = self.next();
2577 }
2578}
2579
2580impl std::fmt::Display for NoteIndex {
2581 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2582 self.0.fmt(f)
2583 }
2584}
2585
2586struct OOBSpendTag;
2587
2588impl sha256t::Tag for OOBSpendTag {
2589 fn engine() -> sha256::HashEngine {
2590 let mut engine = sha256::HashEngine::default();
2591 engine.input(b"oob-spend");
2592 engine
2593 }
2594}
2595
2596struct OOBReissueTag;
2597
2598impl sha256t::Tag for OOBReissueTag {
2599 fn engine() -> sha256::HashEngine {
2600 let mut engine = sha256::HashEngine::default();
2601 engine.input(b"oob-reissue");
2602 engine
2603 }
2604}
2605
2606pub fn represent_amount<K>(
2612 amount: Amount,
2613 current_denominations: &TieredCounts,
2614 tiers: &Tiered<K>,
2615 denomination_sets: u16,
2616 fee_consensus: &FeeConsensus,
2617) -> TieredCounts {
2618 let mut remaining_amount = amount;
2619 let mut denominations = TieredCounts::default();
2620
2621 for tier in tiers.tiers() {
2623 let notes = current_denominations.get(*tier);
2624 let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
2625 let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
2626
2627 let add_notes = min(possible_notes, missing_notes);
2628 denominations.inc(*tier, add_notes as usize);
2629 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
2630 }
2631
2632 for tier in tiers.tiers().rev() {
2634 let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
2635 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
2636 denominations.inc(*tier, res as usize);
2637 }
2638
2639 let represented: u64 = denominations
2640 .iter()
2641 .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
2642 .sum();
2643
2644 assert!(represented <= amount.msats);
2645 assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
2646
2647 denominations
2648}
2649
2650pub(crate) fn create_bundle_for_inputs(
2651 inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
2652 operation_id: OperationId,
2653) -> ClientInputBundle<MintInput, MintClientStateMachines> {
2654 let mut inputs = Vec::new();
2655 let mut input_states = Vec::new();
2656
2657 for (input, spendable_note) in inputs_and_notes {
2658 input_states.push((input.amounts.clone(), spendable_note));
2659 inputs.push(input);
2660 }
2661
2662 let input_sm = Arc::new(move |out_point_range: OutPointRange| {
2663 debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
2664
2665 vec![MintClientStateMachines::Input(MintInputStateMachine {
2666 common: MintInputCommon {
2667 operation_id,
2668 out_point_range,
2669 },
2670 state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
2671 notes: input_states
2672 .iter()
2673 .map(|(amounts, note)| (amounts.expect_only_bitcoin(), *note))
2674 .collect(),
2675 }),
2676 })]
2677 });
2678
2679 ClientInputBundle::new(
2680 inputs,
2681 vec![ClientInputSM {
2682 state_machines: input_sm,
2683 }],
2684 )
2685}
2686
2687#[cfg(test)]
2688mod tests {
2689 use std::fmt::Display;
2690 use std::str::FromStr;
2691
2692 use bitcoin_hashes::Hash;
2693 use fedimint_core::base32::FEDIMINT_PREFIX;
2694 use fedimint_core::config::FederationId;
2695 use fedimint_core::encoding::Decodable;
2696 use fedimint_core::invite_code::InviteCode;
2697 use fedimint_core::module::registry::ModuleRegistry;
2698 use fedimint_core::{
2699 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
2700 };
2701 use fedimint_mint_common::config::FeeConsensus;
2702 use itertools::Itertools;
2703 use serde_json::json;
2704
2705 use crate::{
2706 MintOperationMetaVariant, OOBNotes, OOBNotesPart, SpendableNote, SpendableNoteUndecoded,
2707 represent_amount, select_notes_from_stream,
2708 };
2709
2710 #[test]
2711 fn represent_amount_targets_denomination_sets() {
2712 fn tiers(tiers: Vec<u64>) -> Tiered<()> {
2713 tiers
2714 .into_iter()
2715 .map(|tier| (Amount::from_sats(tier), ()))
2716 .collect()
2717 }
2718
2719 fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
2720 TieredCounts::from_iter(denominations)
2721 }
2722
2723 let starting = notes(vec![
2724 (Amount::from_sats(1), 1),
2725 (Amount::from_sats(2), 3),
2726 (Amount::from_sats(3), 2),
2727 ])
2728 .summary();
2729 let tiers = tiers(vec![1, 2, 3, 4]);
2730
2731 assert_eq!(
2733 represent_amount(
2734 Amount::from_sats(6),
2735 &starting,
2736 &tiers,
2737 3,
2738 &FeeConsensus::zero()
2739 ),
2740 denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
2741 );
2742
2743 assert_eq!(
2745 represent_amount(
2746 Amount::from_sats(6),
2747 &starting,
2748 &tiers,
2749 2,
2750 &FeeConsensus::zero()
2751 ),
2752 denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
2753 );
2754 }
2755
2756 #[test_log::test(tokio::test)]
2757 async fn select_notes_avg_test() {
2758 let max_amount = Amount::from_sats(1_000_000);
2759 let tiers = Tiered::gen_denominations(2, max_amount);
2760 let tiered = represent_amount::<()>(
2761 max_amount,
2762 &TieredCounts::default(),
2763 &tiers,
2764 3,
2765 &FeeConsensus::zero(),
2766 );
2767
2768 let mut total_notes = 0;
2769 for multiplier in 1..100 {
2770 let stream = reverse_sorted_note_stream(tiered.iter().collect());
2771 let select = select_notes_from_stream(
2772 stream,
2773 Amount::from_sats(multiplier * 1000),
2774 FeeConsensus::zero(),
2775 )
2776 .await;
2777 total_notes += select.unwrap().into_iter_items().count();
2778 }
2779 assert_eq!(total_notes / 100, 10);
2780 }
2781
2782 #[test_log::test(tokio::test)]
2783 async fn select_notes_returns_exact_amount_with_minimum_notes() {
2784 let f = || {
2785 reverse_sorted_note_stream(vec![
2786 (Amount::from_sats(1), 10),
2787 (Amount::from_sats(5), 10),
2788 (Amount::from_sats(20), 10),
2789 ])
2790 };
2791 assert_eq!(
2792 select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
2793 .await
2794 .unwrap(),
2795 notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
2796 );
2797 assert_eq!(
2798 select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
2799 .await
2800 .unwrap(),
2801 notes(vec![(Amount::from_sats(20), 1)])
2802 );
2803 }
2804
2805 #[test_log::test(tokio::test)]
2806 async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
2807 let stream = reverse_sorted_note_stream(vec![
2808 (Amount::from_sats(1), 1),
2809 (Amount::from_sats(5), 5),
2810 (Amount::from_sats(20), 5),
2811 ]);
2812 assert_eq!(
2813 select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
2814 .await
2815 .unwrap(),
2816 notes(vec![(Amount::from_sats(5), 2)])
2817 );
2818 }
2819
2820 #[test_log::test(tokio::test)]
2821 async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
2822 let stream = reverse_sorted_note_stream(vec![
2823 (Amount::from_sats(1), 3),
2824 (Amount::from_sats(5), 3),
2825 (Amount::from_sats(20), 2),
2826 ]);
2827 assert_eq!(
2828 select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
2829 .await
2830 .unwrap(),
2831 notes(vec![(Amount::from_sats(20), 2)])
2832 );
2833 }
2834
2835 #[test_log::test(tokio::test)]
2836 async fn select_notes_returns_error_if_amount_is_too_large() {
2837 let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
2838 let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
2839 .await
2840 .unwrap_err();
2841 assert_eq!(error.total_amount, Amount::from_sats(10));
2842 }
2843
2844 fn reverse_sorted_note_stream(
2845 notes: Vec<(Amount, usize)>,
2846 ) -> impl futures::Stream<Item = (Amount, String)> {
2847 futures::stream::iter(
2848 notes
2849 .into_iter()
2850 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2852 .sorted()
2853 .rev(),
2854 )
2855 }
2856
2857 fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
2858 notes
2859 .into_iter()
2860 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2861 .collect()
2862 }
2863
2864 #[test]
2865 fn decoding_empty_oob_notes_fails() {
2866 let empty_oob_notes =
2867 OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
2868 let oob_notes_string = empty_oob_notes.to_string();
2869
2870 let res = oob_notes_string.parse::<OOBNotes>();
2871
2872 assert!(res.is_err(), "An empty OOB notes string should not parse");
2873 }
2874
2875 fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
2876 where
2877 T: FromStr + Display + crate::Encodable + crate::Decodable,
2878 <T as FromStr>::Err: std::fmt::Debug,
2879 F: Fn(T),
2880 {
2881 let data_parsed = data.to_string().parse().expect("Deserialization failed");
2882
2883 assertions(data_parsed);
2884
2885 let data_parsed = crate::base32::encode_prefixed(FEDIMINT_PREFIX, &data)
2886 .parse()
2887 .expect("Deserialization failed");
2888
2889 assertions(data_parsed);
2890
2891 assertions(data);
2892 }
2893
2894 #[test]
2895 fn notes_encode_decode() {
2896 let federation_id_1 =
2897 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
2898 let federation_id_prefix_1 = federation_id_1.to_prefix();
2899 let federation_id_2 =
2900 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
2901 let federation_id_prefix_2 = federation_id_2.to_prefix();
2902
2903 let notes = vec![(
2904 Amount::from_sats(1),
2905 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
2906 )]
2907 .into_iter()
2908 .collect::<TieredMulti<_>>();
2909
2910 let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
2912 test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
2913 assert_eq!(oob_notes.notes(), ¬es);
2914 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2915 assert_eq!(oob_notes.federation_invite(), None);
2916 });
2917
2918 let invite = InviteCode::new(
2920 "wss://foo.bar".parse().unwrap(),
2921 PeerId::from(0),
2922 federation_id_1,
2923 None,
2924 );
2925 let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
2926 test_roundtrip_serialize_str(notes_invite, |oob_notes| {
2927 assert_eq!(oob_notes.notes(), ¬es);
2928 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2929 assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
2930 });
2931
2932 let notes_no_prefix = OOBNotes(vec![
2935 OOBNotesPart::Notes(notes.clone()),
2936 OOBNotesPart::Invite {
2937 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2938 federation_id: federation_id_1,
2939 },
2940 ]);
2941 test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
2942 assert_eq!(oob_notes.notes(), ¬es);
2943 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2944 });
2945
2946 let notes_inconsistent = OOBNotes(vec![
2948 OOBNotesPart::Notes(notes),
2949 OOBNotesPart::Invite {
2950 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2951 federation_id: federation_id_1,
2952 },
2953 OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
2954 ]);
2955 let notes_inconsistent_str = notes_inconsistent.to_string();
2956 assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
2957 }
2958
2959 #[test]
2960 fn spendable_note_undecoded_sanity() {
2961 #[allow(clippy::single_element_loop)]
2963 for note_hex in [
2964 "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
2965 ] {
2966 let note =
2967 SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
2968 let note_undecoded =
2969 SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
2970 .unwrap()
2971 .decode()
2972 .unwrap();
2973 assert_eq!(note, note_undecoded,);
2974 assert_eq!(
2975 serde_json::to_string(¬e).unwrap(),
2976 serde_json::to_string(¬e_undecoded).unwrap(),
2977 );
2978 }
2979 }
2980
2981 #[test]
2982 fn reissuance_meta_compatibility_02_03() {
2983 let dummy_outpoint = OutPoint {
2984 txid: TransactionId::all_zeros(),
2985 out_idx: 0,
2986 };
2987
2988 let old_meta_json = json!({
2989 "reissuance": {
2990 "out_point": dummy_outpoint
2991 }
2992 });
2993
2994 let old_meta: MintOperationMetaVariant =
2995 serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
2996 assert_eq!(
2997 old_meta,
2998 MintOperationMetaVariant::Reissuance {
2999 legacy_out_point: Some(dummy_outpoint),
3000 txid: None,
3001 out_point_indices: vec![],
3002 }
3003 );
3004
3005 let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
3006 legacy_out_point: None,
3007 txid: Some(dummy_outpoint.txid),
3008 out_point_indices: vec![0],
3009 })
3010 .expect("serializing always works");
3011 assert_eq!(
3012 new_meta_json,
3013 json!({
3014 "reissuance": {
3015 "txid": dummy_outpoint.txid,
3016 "out_point_indices": [dummy_outpoint.out_idx],
3017 }
3018 })
3019 );
3020 }
3021}