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};
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 })
565 }
566
567 async fn recover(
568 &self,
569 args: &ClientModuleRecoverArgs<Self>,
570 snapshot: Option<&<Self::Module as ClientModule>::Backup>,
571 ) -> anyhow::Result<()> {
572 args.recover_from_history::<MintRecovery>(self, snapshot)
573 .await
574 }
575
576 fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
577 let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = BTreeMap::new();
578 migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
579 Box::pin(migrate_to_v1(dbtx))
580 });
581 migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
582 Box::pin(async { migrate_state(active_states, inactive_states, migrate_state_to_v2) })
583 });
584
585 migrations
586 }
587
588 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
589 Some(
590 DbKeyPrefix::iter()
591 .map(|p| p as u8)
592 .chain(
593 DbKeyPrefix::ExternalReservedStart as u8
594 ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
595 )
596 .collect(),
597 )
598 }
599}
600
601#[derive(Debug)]
627pub 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}
635
636#[derive(Debug, Clone)]
638pub struct MintClientContext {
639 pub client_ctx: ClientContext<MintClientModule>,
640 pub mint_decoder: Decoder,
641 pub tbs_pks: Tiered<AggregatePublicKey>,
642 pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>,
643 pub secret: DerivableSecret,
644 pub module_db: Database,
647}
648
649impl MintClientContext {
650 fn await_cancel_oob_payment(&self, operation_id: OperationId) -> BoxFuture<'static, ()> {
651 let db = self.module_db.clone();
652 Box::pin(async move {
653 db.wait_key_exists(&CancelledOOBSpendKey(operation_id))
654 .await;
655 })
656 }
657}
658
659impl Context for MintClientContext {
660 const KIND: Option<ModuleKind> = Some(KIND);
661}
662
663#[apply(async_trait_maybe_send!)]
664impl ClientModule for MintClientModule {
665 type Init = MintClientInit;
666 type Common = MintModuleTypes;
667 type Backup = EcashBackup;
668 type ModuleStateMachineContext = MintClientContext;
669 type States = MintClientStateMachines;
670
671 fn context(&self) -> Self::ModuleStateMachineContext {
672 MintClientContext {
673 client_ctx: self.client_ctx.clone(),
674 mint_decoder: self.decoder(),
675 tbs_pks: self.cfg.tbs_pks.clone(),
676 peer_tbs_pks: self.cfg.peer_tbs_pks.clone(),
677 secret: self.secret.clone(),
678 module_db: self.client_ctx.module_db().clone(),
679 }
680 }
681
682 fn input_fee(
683 &self,
684 amount: &Amounts,
685 _input: &<Self::Common as ModuleCommon>::Input,
686 ) -> Option<Amounts> {
687 Some(Amounts::new_bitcoin(
688 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
689 ))
690 }
691
692 fn output_fee(
693 &self,
694 amount: &Amounts,
695 _output: &<Self::Common as ModuleCommon>::Output,
696 ) -> Option<Amounts> {
697 Some(Amounts::new_bitcoin(
698 self.cfg.fee_consensus.fee(amount.get_bitcoin()),
699 ))
700 }
701
702 #[cfg(feature = "cli")]
703 async fn handle_cli_command(
704 &self,
705 args: &[std::ffi::OsString],
706 ) -> anyhow::Result<serde_json::Value> {
707 cli::handle_cli_command(self, args).await
708 }
709
710 fn supports_backup(&self) -> bool {
711 true
712 }
713
714 async fn backup(&self) -> anyhow::Result<EcashBackup> {
715 self.client_ctx
716 .module_db()
717 .autocommit(
718 |dbtx_ctx, _| {
719 Box::pin(async { self.prepare_plaintext_ecash_backup(dbtx_ctx).await })
720 },
721 None,
722 )
723 .await
724 .map_err(|e| match e {
725 AutocommitError::ClosureError { error, .. } => error,
726 AutocommitError::CommitFailed { last_error, .. } => {
727 anyhow!("Commit to DB failed: {last_error}")
728 }
729 })
730 }
731
732 fn supports_being_primary(&self) -> PrimaryModuleSupport {
733 PrimaryModuleSupport::selected(PrimaryModulePriority::HIGH, [AmountUnit::BITCOIN])
734 }
735
736 async fn create_final_inputs_and_outputs(
737 &self,
738 dbtx: &mut DatabaseTransaction<'_>,
739 operation_id: OperationId,
740 unit: AmountUnit,
741 mut input_amount: Amount,
742 mut output_amount: Amount,
743 ) -> anyhow::Result<(
744 ClientInputBundle<MintInput, MintClientStateMachines>,
745 ClientOutputBundle<MintOutput, MintClientStateMachines>,
746 )> {
747 let consolidation_inputs = self.consolidate_notes(dbtx).await?;
748
749 if unit != AmountUnit::BITCOIN {
750 bail!("Module can only handle Bitcoin");
751 }
752
753 input_amount += consolidation_inputs
754 .iter()
755 .map(|input| input.0.amounts.get_bitcoin())
756 .sum();
757
758 output_amount += consolidation_inputs
759 .iter()
760 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
761 .sum();
762
763 let additional_inputs = self
764 .create_sufficient_input(dbtx, output_amount.saturating_sub(input_amount))
765 .await?;
766
767 input_amount += additional_inputs
768 .iter()
769 .map(|input| input.0.amounts.get_bitcoin())
770 .sum();
771
772 output_amount += additional_inputs
773 .iter()
774 .map(|input| self.cfg.fee_consensus.fee(input.0.amounts.get_bitcoin()))
775 .sum();
776
777 let outputs = self
778 .create_output(
779 dbtx,
780 operation_id,
781 2,
782 input_amount.saturating_sub(output_amount),
783 )
784 .await;
785
786 Ok((
787 create_bundle_for_inputs(
788 [consolidation_inputs, additional_inputs].concat(),
789 operation_id,
790 ),
791 outputs,
792 ))
793 }
794
795 async fn await_primary_module_output(
796 &self,
797 operation_id: OperationId,
798 out_point: OutPoint,
799 ) -> anyhow::Result<()> {
800 self.await_output_finalized(operation_id, out_point).await
801 }
802
803 async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
804 if unit != AmountUnit::BITCOIN {
805 return Amount::ZERO;
806 }
807 self.get_note_counts_by_denomination(dbtx)
808 .await
809 .total_amount()
810 }
811
812 async fn get_balances(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
813 Amounts::new_bitcoin(
814 <Self as ClientModule>::get_balance(self, dbtx, AmountUnit::BITCOIN).await,
815 )
816 }
817
818 async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
819 Box::pin(
820 self.notifier
821 .subscribe_all_operations()
822 .filter_map(|state| async move {
823 #[allow(deprecated)]
824 match state {
825 MintClientStateMachines::Output(MintOutputStateMachine {
826 state: MintOutputStates::Succeeded(_),
827 ..
828 })
829 | MintClientStateMachines::Input(MintInputStateMachine {
830 state: MintInputStates::Created(_) | MintInputStates::CreatedBundle(_),
831 ..
832 })
833 | MintClientStateMachines::OOB(MintOOBStateMachine {
834 state: MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_),
835 ..
836 }) => Some(()),
837 MintClientStateMachines::Output(MintOutputStateMachine {
840 state:
841 MintOutputStates::Created(_)
842 | MintOutputStates::CreatedMulti(_)
843 | MintOutputStates::Failed(_)
844 | MintOutputStates::Aborted(_),
845 ..
846 })
847 | MintClientStateMachines::Input(MintInputStateMachine {
848 state:
849 MintInputStates::Error(_)
850 | MintInputStates::Success(_)
851 | MintInputStates::Refund(_)
852 | MintInputStates::RefundedBundle(_)
853 | MintInputStates::RefundedPerNote(_)
854 | MintInputStates::RefundSuccess(_),
855 ..
856 })
857 | MintClientStateMachines::OOB(MintOOBStateMachine {
858 state:
859 MintOOBStates::TimeoutRefund(_)
860 | MintOOBStates::UserRefund(_)
861 | MintOOBStates::UserRefundMulti(_),
862 ..
863 })
864 | MintClientStateMachines::Restore(_) => None,
865 }
866 }),
867 )
868 }
869
870 async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
871 let balance = ClientModule::get_balances(self, dbtx).await;
872
873 for (unit, amount) in balance {
874 if Amount::from_units(0) < amount {
875 bail!("Outstanding balance: {amount}, unit: {unit:?}");
876 }
877 }
878
879 if !self.client_ctx.get_own_active_states().await.is_empty() {
880 bail!("Pending operations")
881 }
882 Ok(())
883 }
884
885 async fn handle_rpc(
886 &self,
887 method: String,
888 request: serde_json::Value,
889 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
890 Box::pin(try_stream! {
891 match method.as_str() {
892 "reissue_external_notes" => {
893 let req: ReissueExternalNotesRequest = serde_json::from_value(request)?;
894 let result = self.reissue_external_notes(req.oob_notes, req.extra_meta).await?;
895 yield serde_json::to_value(result)?;
896 }
897 "subscribe_reissue_external_notes" => {
898 let req: SubscribeReissueExternalNotesRequest = serde_json::from_value(request)?;
899 let stream = self.subscribe_reissue_external_notes(req.operation_id).await?;
900 for await state in stream.into_stream() {
901 yield serde_json::to_value(state)?;
902 }
903 }
904 "spend_notes" => {
905 let req: SpendNotesRequest = serde_json::from_value(request)?;
906 let result = self.spend_notes_with_selector(
907 &SelectNotesWithExactAmount,
908 req.amount,
909 req.try_cancel_after,
910 req.include_invite,
911 req.extra_meta
912 ).await?;
913 yield serde_json::to_value(result)?;
914 }
915 "spend_notes_expert" => {
916 let req: SpendNotesExpertRequest = serde_json::from_value(request)?;
917 let result = self.spend_notes_with_selector(
918 &SelectNotesWithAtleastAmount,
919 req.min_amount,
920 req.try_cancel_after,
921 req.include_invite,
922 req.extra_meta
923 ).await?;
924 yield serde_json::to_value(result)?;
925 }
926 "validate_notes" => {
927 let req: ValidateNotesRequest = serde_json::from_value(request)?;
928 let result = self.validate_notes(&req.oob_notes)?;
929 yield serde_json::to_value(result)?;
930 }
931 "try_cancel_spend_notes" => {
932 let req: TryCancelSpendNotesRequest = serde_json::from_value(request)?;
933 let result = self.try_cancel_spend_notes(req.operation_id).await;
934 yield serde_json::to_value(result)?;
935 }
936 "subscribe_spend_notes" => {
937 let req: SubscribeSpendNotesRequest = serde_json::from_value(request)?;
938 let stream = self.subscribe_spend_notes(req.operation_id).await?;
939 for await state in stream.into_stream() {
940 yield serde_json::to_value(state)?;
941 }
942 }
943 "await_spend_oob_refund" => {
944 let req: AwaitSpendOobRefundRequest = serde_json::from_value(request)?;
945 let value = self.await_spend_oob_refund(req.operation_id).await;
946 yield serde_json::to_value(value)?;
947 }
948 "note_counts_by_denomination" => {
949 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
950 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
951 yield serde_json::to_value(note_counts)?;
952 }
953 _ => {
954 Err(anyhow::format_err!("Unknown method: {}", method))?;
955 unreachable!()
956 },
957 }
958 })
959 }
960}
961
962#[derive(Deserialize)]
963struct ReissueExternalNotesRequest {
964 oob_notes: OOBNotes,
965 extra_meta: serde_json::Value,
966}
967
968#[derive(Deserialize)]
969struct SubscribeReissueExternalNotesRequest {
970 operation_id: OperationId,
971}
972
973#[derive(Deserialize)]
976struct SpendNotesExpertRequest {
977 min_amount: Amount,
978 try_cancel_after: Duration,
979 include_invite: bool,
980 extra_meta: serde_json::Value,
981}
982
983#[derive(Deserialize)]
984struct SpendNotesRequest {
985 amount: Amount,
986 try_cancel_after: Duration,
987 include_invite: bool,
988 extra_meta: serde_json::Value,
989}
990
991#[derive(Deserialize)]
992struct ValidateNotesRequest {
993 oob_notes: OOBNotes,
994}
995
996#[derive(Deserialize)]
997struct TryCancelSpendNotesRequest {
998 operation_id: OperationId,
999}
1000
1001#[derive(Deserialize)]
1002struct SubscribeSpendNotesRequest {
1003 operation_id: OperationId,
1004}
1005
1006#[derive(Deserialize)]
1007struct AwaitSpendOobRefundRequest {
1008 operation_id: OperationId,
1009}
1010
1011#[derive(thiserror::Error, Debug, Clone)]
1012pub enum ReissueExternalNotesError {
1013 #[error("Federation ID does not match")]
1014 WrongFederationId,
1015 #[error("We already reissued these notes")]
1016 AlreadyReissued,
1017}
1018
1019impl MintClientModule {
1020 async fn create_sufficient_input(
1021 &self,
1022 dbtx: &mut DatabaseTransaction<'_>,
1023 min_amount: Amount,
1024 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1025 if min_amount == Amount::ZERO {
1026 return Ok(vec![]);
1027 }
1028
1029 let selected_notes = Self::select_notes(
1030 dbtx,
1031 &SelectNotesWithAtleastAmount,
1032 min_amount,
1033 self.cfg.fee_consensus.clone(),
1034 )
1035 .await?;
1036
1037 for (amount, note) in selected_notes.iter_items() {
1038 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as sufficient input to fund a tx");
1039 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1040 }
1041
1042 let inputs = self.create_input_from_notes(selected_notes)?;
1043
1044 assert!(!inputs.is_empty());
1045
1046 Ok(inputs)
1047 }
1048
1049 #[deprecated(
1051 since = "0.5.0",
1052 note = "Use `get_note_counts_by_denomination` instead"
1053 )]
1054 pub async fn get_notes_tier_counts(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1055 self.get_note_counts_by_denomination(dbtx).await
1056 }
1057
1058 pub async fn get_available_notes_by_tier_counts(
1062 &self,
1063 dbtx: &mut DatabaseTransaction<'_>,
1064 counts: TieredCounts,
1065 ) -> (TieredMulti<SpendableNoteUndecoded>, TieredCounts) {
1066 dbtx.find_by_prefix(&NoteKeyPrefix)
1067 .await
1068 .fold(
1069 (TieredMulti::<SpendableNoteUndecoded>::default(), counts),
1070 |(mut notes, mut counts), (key, note)| async move {
1071 let amount = key.amount;
1072 if 0 < counts.get(amount) {
1073 counts.dec(amount);
1074 notes.push(amount, note);
1075 }
1076
1077 (notes, counts)
1078 },
1079 )
1080 .await
1081 }
1082
1083 pub async fn create_output(
1088 &self,
1089 dbtx: &mut DatabaseTransaction<'_>,
1090 operation_id: OperationId,
1091 notes_per_denomination: u16,
1092 exact_amount: Amount,
1093 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1094 if exact_amount == Amount::ZERO {
1095 return ClientOutputBundle::new(vec![], vec![]);
1096 }
1097
1098 let denominations = represent_amount(
1099 exact_amount,
1100 &self.get_note_counts_by_denomination(dbtx).await,
1101 &self.cfg.tbs_pks,
1102 notes_per_denomination,
1103 &self.cfg.fee_consensus,
1104 );
1105
1106 let mut outputs = Vec::new();
1107 let mut issuance_requests = Vec::new();
1108
1109 for (amount, num) in denominations.iter() {
1110 for _ in 0..num {
1111 let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await;
1112
1113 debug!(
1114 %amount,
1115 "Generated issuance request"
1116 );
1117
1118 outputs.push(ClientOutput {
1119 output: MintOutput::new_v0(amount, blind_nonce),
1120 amounts: Amounts::new_bitcoin(amount),
1121 });
1122
1123 issuance_requests.push((amount, issuance_request));
1124 }
1125 }
1126
1127 let state_generator = Arc::new(move |out_point_range: OutPointRange| {
1128 assert_eq!(out_point_range.count(), issuance_requests.len());
1129 vec![MintClientStateMachines::Output(MintOutputStateMachine {
1130 common: MintOutputCommon {
1131 operation_id,
1132 out_point_range,
1133 },
1134 state: MintOutputStates::CreatedMulti(MintOutputStatesCreatedMulti {
1135 issuance_requests: out_point_range
1136 .into_iter()
1137 .map(|out_point| out_point.out_idx)
1138 .zip(issuance_requests.clone())
1139 .collect(),
1140 }),
1141 })]
1142 });
1143
1144 ClientOutputBundle::new(
1145 outputs,
1146 vec![ClientOutputSM {
1147 state_machines: state_generator,
1148 }],
1149 )
1150 }
1151
1152 pub async fn get_note_counts_by_denomination(
1154 &self,
1155 dbtx: &mut DatabaseTransaction<'_>,
1156 ) -> TieredCounts {
1157 dbtx.find_by_prefix(&NoteKeyPrefix)
1158 .await
1159 .fold(
1160 TieredCounts::default(),
1161 |mut acc, (key, _note)| async move {
1162 acc.inc(key.amount, 1);
1163 acc
1164 },
1165 )
1166 .await
1167 }
1168
1169 #[deprecated(
1171 since = "0.5.0",
1172 note = "Use `get_note_counts_by_denomination` instead"
1173 )]
1174 pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1175 self.get_note_counts_by_denomination(dbtx).await
1176 }
1177
1178 pub async fn await_output_finalized(
1182 &self,
1183 operation_id: OperationId,
1184 out_point: OutPoint,
1185 ) -> anyhow::Result<()> {
1186 let stream = self
1187 .notifier
1188 .subscribe(operation_id)
1189 .await
1190 .filter_map(|state| async {
1191 let MintClientStateMachines::Output(state) = state else {
1192 return None;
1193 };
1194
1195 if state.common.txid() != out_point.txid
1196 || !state
1197 .common
1198 .out_point_range
1199 .out_idx_iter()
1200 .contains(&out_point.out_idx)
1201 {
1202 return None;
1203 }
1204
1205 match state.state {
1206 MintOutputStates::Succeeded(_) => Some(Ok(())),
1207 MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
1208 MintOutputStates::Failed(failed) => Some(Err(anyhow!(
1209 "Failed to finalize transaction: {}",
1210 failed.error
1211 ))),
1212 MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1213 }
1214 });
1215 pin_mut!(stream);
1216
1217 stream.next_or_pending().await
1218 }
1219
1220 pub async fn consolidate_notes(
1227 &self,
1228 dbtx: &mut DatabaseTransaction<'_>,
1229 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1230 const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1233 const MIN_NOTES_PER_TIER: usize = 4;
1235 const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1238 #[allow(clippy::assertions_on_constants)]
1240 {
1241 assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1242 }
1243
1244 let counts = self.get_note_counts_by_denomination(dbtx).await;
1245
1246 let should_consolidate = counts
1247 .iter()
1248 .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1249
1250 if !should_consolidate {
1251 return Ok(vec![]);
1252 }
1253
1254 let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1255
1256 let excessive_counts: TieredCounts = counts
1257 .iter()
1258 .map(|(amount, count)| {
1259 let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1260
1261 max_count -= take;
1262 (amount, take)
1263 })
1264 .collect();
1265
1266 let (selected_notes, unavailable) = self
1267 .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1268 .await;
1269
1270 debug_assert!(
1271 unavailable.is_empty(),
1272 "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1273 );
1274
1275 if !selected_notes.is_empty() {
1276 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");
1277 }
1278
1279 let mut selected_notes_decoded = vec![];
1280 for (amount, note) in selected_notes.iter_items() {
1281 let spendable_note_decoded = note.decode()?;
1282 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1283 Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1284 .await;
1285 selected_notes_decoded.push((amount, spendable_note_decoded));
1286 }
1287
1288 self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1289 }
1290
1291 #[allow(clippy::type_complexity)]
1293 pub fn create_input_from_notes(
1294 &self,
1295 notes: TieredMulti<SpendableNote>,
1296 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1297 let mut inputs_and_notes = Vec::new();
1298
1299 for (amount, spendable_note) in notes.into_iter_items() {
1300 let key = self
1301 .cfg
1302 .tbs_pks
1303 .get(amount)
1304 .ok_or(anyhow!("Invalid amount tier: {amount}"))?;
1305
1306 let note = spendable_note.note();
1307
1308 if !note.verify(*key) {
1309 bail!("Invalid note");
1310 }
1311
1312 inputs_and_notes.push((
1313 ClientInput {
1314 input: MintInput::new_v0(amount, note),
1315 keys: vec![spendable_note.spend_key],
1316 amounts: Amounts::new_bitcoin(amount),
1317 },
1318 spendable_note,
1319 ));
1320 }
1321
1322 Ok(inputs_and_notes)
1323 }
1324
1325 async fn spend_notes_oob(
1326 &self,
1327 dbtx: &mut DatabaseTransaction<'_>,
1328 notes_selector: &impl NotesSelector,
1329 amount: Amount,
1330 try_cancel_after: Duration,
1331 ) -> anyhow::Result<(
1332 OperationId,
1333 Vec<MintClientStateMachines>,
1334 TieredMulti<SpendableNote>,
1335 )> {
1336 ensure!(
1337 amount > Amount::ZERO,
1338 "zero-amount out-of-band spends are not supported"
1339 );
1340
1341 let selected_notes =
1342 Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1343
1344 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1345
1346 for (amount, note) in selected_notes.iter_items() {
1347 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1348 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1349 }
1350
1351 let state_machines = vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1352 operation_id,
1353 state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1354 spendable_notes: selected_notes.clone().into_iter_items().collect(),
1355 timeout: fedimint_core::time::now() + try_cancel_after,
1356 }),
1357 })];
1358
1359 Ok((operation_id, state_machines, selected_notes))
1360 }
1361
1362 pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1363 Box::pin(
1364 self.notifier
1365 .subscribe(operation_id)
1366 .await
1367 .filter_map(|state| async {
1368 let MintClientStateMachines::OOB(state) = state else {
1369 return None;
1370 };
1371
1372 match state.state {
1373 MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1374 user_triggered: false,
1375 transaction_ids: vec![refund.refund_txid],
1376 }),
1377 MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1378 user_triggered: true,
1379 transaction_ids: vec![refund.refund_txid],
1380 }),
1381 MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1382 user_triggered: true,
1383 transaction_ids: vec![refund.refund_txid],
1384 }),
1385 MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1386 }
1387 }),
1388 )
1389 .next_or_pending()
1390 .await
1391 }
1392
1393 async fn select_notes(
1395 dbtx: &mut DatabaseTransaction<'_>,
1396 notes_selector: &impl NotesSelector,
1397 requested_amount: Amount,
1398 fee_consensus: FeeConsensus,
1399 ) -> anyhow::Result<TieredMulti<SpendableNote>> {
1400 let note_stream = dbtx
1401 .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1402 .await
1403 .map(|(key, note)| (key.amount, note));
1404
1405 notes_selector
1406 .select_notes(note_stream, requested_amount, fee_consensus)
1407 .await?
1408 .into_iter_items()
1409 .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1410 .collect::<anyhow::Result<TieredMulti<_>>>()
1411 }
1412
1413 async fn get_all_spendable_notes(
1414 dbtx: &mut DatabaseTransaction<'_>,
1415 ) -> TieredMulti<SpendableNoteUndecoded> {
1416 (dbtx
1417 .find_by_prefix(&NoteKeyPrefix)
1418 .await
1419 .map(|(key, note)| (key.amount, note))
1420 .collect::<Vec<_>>()
1421 .await)
1422 .into_iter()
1423 .collect()
1424 }
1425
1426 async fn get_next_note_index(
1427 &self,
1428 dbtx: &mut DatabaseTransaction<'_>,
1429 amount: Amount,
1430 ) -> NoteIndex {
1431 NoteIndex(
1432 dbtx.get_value(&NextECashNoteIndexKey(amount))
1433 .await
1434 .unwrap_or(0),
1435 )
1436 }
1437
1438 pub fn new_note_secret_static(
1454 secret: &DerivableSecret,
1455 amount: Amount,
1456 note_idx: NoteIndex,
1457 ) -> DerivableSecret {
1458 assert_eq!(secret.level(), 2);
1459 debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1460 secret
1461 .child_key(MINT_E_CASH_TYPE_CHILD_ID) .child_key(ChildId(note_idx.as_u64()))
1463 .child_key(ChildId(amount.msats))
1464 }
1465
1466 async fn new_note_secret(
1470 &self,
1471 amount: Amount,
1472 dbtx: &mut DatabaseTransaction<'_>,
1473 ) -> DerivableSecret {
1474 let new_idx = self.get_next_note_index(dbtx, amount).await;
1475 dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1476 .await;
1477 Self::new_note_secret_static(&self.secret, amount, new_idx)
1478 }
1479
1480 pub async fn new_ecash_note(
1481 &self,
1482 amount: Amount,
1483 dbtx: &mut DatabaseTransaction<'_>,
1484 ) -> (NoteIssuanceRequest, BlindNonce) {
1485 let secret = self.new_note_secret(amount, dbtx).await;
1486 NoteIssuanceRequest::new(&self.secp, &secret)
1487 }
1488
1489 pub async fn reissue_external_notes<M: Serialize + Send>(
1494 &self,
1495 oob_notes: OOBNotes,
1496 extra_meta: M,
1497 ) -> anyhow::Result<OperationId> {
1498 let notes = oob_notes.notes().clone();
1499 let federation_id_prefix = oob_notes.federation_id_prefix();
1500
1501 ensure!(
1502 notes.total_amount() > Amount::ZERO,
1503 "Reissuing zero-amount e-cash isn't supported"
1504 );
1505
1506 if federation_id_prefix != self.federation_id.to_prefix() {
1507 bail!(ReissueExternalNotesError::WrongFederationId);
1508 }
1509
1510 let operation_id = OperationId(
1511 notes
1512 .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
1513 .to_byte_array(),
1514 );
1515
1516 let amount = notes.total_amount();
1517 let mint_inputs = self.create_input_from_notes(notes)?;
1518
1519 let tx = TransactionBuilder::new().with_inputs(
1520 self.client_ctx
1521 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
1522 );
1523
1524 let extra_meta = serde_json::to_value(extra_meta)
1525 .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
1526 let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
1527 variant: MintOperationMetaVariant::Reissuance {
1528 legacy_out_point: None,
1529 txid: Some(change_range.txid()),
1530 out_point_indices: change_range
1531 .into_iter()
1532 .map(|out_point| out_point.out_idx)
1533 .collect(),
1534 },
1535 amount,
1536 extra_meta: extra_meta.clone(),
1537 };
1538
1539 self.client_ctx
1540 .finalize_and_submit_transaction(
1541 operation_id,
1542 MintCommonInit::KIND.as_str(),
1543 operation_meta_gen,
1544 tx,
1545 )
1546 .await
1547 .context(ReissueExternalNotesError::AlreadyReissued)?;
1548 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1549 self.client_ctx
1550 .log_event(&mut dbtx, OOBNotesReissued { amount })
1551 .await;
1552 dbtx.commit_tx().await;
1553
1554 Ok(operation_id)
1555 }
1556
1557 pub async fn subscribe_reissue_external_notes(
1560 &self,
1561 operation_id: OperationId,
1562 ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
1563 let operation = self.mint_operation(operation_id).await?;
1564 let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
1565 MintOperationMetaVariant::Reissuance {
1566 legacy_out_point,
1567 txid,
1568 out_point_indices,
1569 } => {
1570 let txid = txid
1573 .or(legacy_out_point.map(|out_point| out_point.txid))
1574 .context("Empty reissuance not permitted, this should never happen")?;
1575
1576 let out_points = out_point_indices
1577 .into_iter()
1578 .map(|out_idx| OutPoint { txid, out_idx })
1579 .chain(legacy_out_point)
1580 .collect::<Vec<_>>();
1581
1582 (txid, out_points)
1583 }
1584 MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"),
1585 MintOperationMetaVariant::Recovery { .. } => unimplemented!(),
1586 };
1587
1588 let client_ctx = self.client_ctx.clone();
1589
1590 Ok(self.client_ctx.outcome_or_updates(operation, operation_id, move || {
1591 stream! {
1592 yield ReissueExternalNotesState::Created;
1593
1594 match client_ctx
1595 .transaction_updates(operation_id)
1596 .await
1597 .await_tx_accepted(txid)
1598 .await
1599 {
1600 Ok(()) => {
1601 yield ReissueExternalNotesState::Issuing;
1602 }
1603 Err(e) => {
1604 yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
1605 return;
1606 }
1607 }
1608
1609 for out_point in out_points {
1610 if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
1611 yield ReissueExternalNotesState::Failed(e.to_string());
1612 return;
1613 }
1614 }
1615 yield ReissueExternalNotesState::Done;
1616 }}
1617 ))
1618 }
1619
1620 #[deprecated(
1632 since = "0.5.0",
1633 note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
1634 )]
1635 pub async fn spend_notes<M: Serialize + Send>(
1636 &self,
1637 min_amount: Amount,
1638 try_cancel_after: Duration,
1639 include_invite: bool,
1640 extra_meta: M,
1641 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1642 self.spend_notes_with_selector(
1643 &SelectNotesWithAtleastAmount,
1644 min_amount,
1645 try_cancel_after,
1646 include_invite,
1647 extra_meta,
1648 )
1649 .await
1650 }
1651
1652 pub async fn spend_notes_with_selector<M: Serialize + Send>(
1668 &self,
1669 notes_selector: &impl NotesSelector,
1670 requested_amount: Amount,
1671 try_cancel_after: Duration,
1672 include_invite: bool,
1673 extra_meta: M,
1674 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1675 let federation_id_prefix = self.federation_id.to_prefix();
1676 let extra_meta = serde_json::to_value(extra_meta)
1677 .expect("MintClientModule::spend_notes extra_meta is serializable");
1678
1679 self.client_ctx
1680 .module_db()
1681 .autocommit(
1682 |dbtx, _| {
1683 let extra_meta = extra_meta.clone();
1684 Box::pin(async {
1685 let (operation_id, states, notes) = self
1686 .spend_notes_oob(
1687 dbtx,
1688 notes_selector,
1689 requested_amount,
1690 try_cancel_after,
1691 )
1692 .await?;
1693
1694 let oob_notes = if include_invite {
1695 OOBNotes::new_with_invite(
1696 notes,
1697 &self.client_ctx.get_invite_code().await,
1698 )
1699 } else {
1700 OOBNotes::new(federation_id_prefix, notes)
1701 };
1702
1703 self.client_ctx
1704 .add_state_machines_dbtx(
1705 dbtx,
1706 self.client_ctx.map_dyn(states).collect(),
1707 )
1708 .await?;
1709 self.client_ctx
1710 .add_operation_log_entry_dbtx(
1711 dbtx,
1712 operation_id,
1713 MintCommonInit::KIND.as_str(),
1714 MintOperationMeta {
1715 variant: MintOperationMetaVariant::SpendOOB {
1716 requested_amount,
1717 oob_notes: oob_notes.clone(),
1718 },
1719 amount: oob_notes.total_amount(),
1720 extra_meta,
1721 },
1722 )
1723 .await;
1724 self.client_ctx
1725 .log_event(
1726 dbtx,
1727 OOBNotesSpent {
1728 requested_amount,
1729 spent_amount: oob_notes.total_amount(),
1730 timeout: try_cancel_after,
1731 include_invite,
1732 },
1733 )
1734 .await;
1735
1736 Ok((operation_id, oob_notes))
1737 })
1738 },
1739 Some(100),
1740 )
1741 .await
1742 .map_err(|e| match e {
1743 AutocommitError::ClosureError { error, .. } => error,
1744 AutocommitError::CommitFailed { last_error, .. } => {
1745 anyhow!("Commit to DB failed: {last_error}")
1746 }
1747 })
1748 }
1749
1750 pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> {
1756 let federation_id_prefix = oob_notes.federation_id_prefix();
1757 let notes = oob_notes.notes().clone();
1758
1759 if federation_id_prefix != self.federation_id.to_prefix() {
1760 bail!("Federation ID does not match");
1761 }
1762
1763 let tbs_pks = &self.cfg.tbs_pks;
1764
1765 for (idx, (amt, snote)) in notes.iter_items().enumerate() {
1766 let key = tbs_pks
1767 .get(amt)
1768 .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
1769
1770 let note = snote.note();
1771 if !note.verify(*key) {
1772 bail!("Note {idx} has an invalid federation signature");
1773 }
1774
1775 let expected_nonce = Nonce(snote.spend_key.public_key());
1776 if note.nonce != expected_nonce {
1777 bail!("Note {idx} cannot be spent using the supplied spend key");
1778 }
1779 }
1780
1781 Ok(notes.total_amount())
1782 }
1783
1784 pub async fn check_note_spent(&self, oob_notes: &OOBNotes) -> anyhow::Result<bool> {
1790 use crate::api::MintFederationApi;
1791
1792 let api_client = self.client_ctx.module_api();
1793 let any_spent = try_join_all(oob_notes.notes().iter().flat_map(|(_, notes)| {
1794 notes
1795 .iter()
1796 .map(|note| api_client.check_note_spent(note.nonce()))
1797 }))
1798 .await?
1799 .into_iter()
1800 .any(|spent| spent);
1801
1802 Ok(any_spent)
1803 }
1804
1805 pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
1810 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1811 dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
1812 .await;
1813 if let Err(e) = dbtx.commit_tx_result().await {
1814 warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
1815 }
1816 }
1817
1818 pub async fn subscribe_spend_notes(
1821 &self,
1822 operation_id: OperationId,
1823 ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
1824 let operation = self.mint_operation(operation_id).await?;
1825 if !matches!(
1826 operation.meta::<MintOperationMeta>().variant,
1827 MintOperationMetaVariant::SpendOOB { .. }
1828 ) {
1829 bail!("Operation is not a out-of-band spend");
1830 }
1831
1832 let client_ctx = self.client_ctx.clone();
1833
1834 Ok(self
1835 .client_ctx
1836 .outcome_or_updates(operation, operation_id, move || {
1837 stream! {
1838 yield SpendOOBState::Created;
1839
1840 let self_ref = client_ctx.self_ref();
1841
1842 let refund = self_ref
1843 .await_spend_oob_refund(operation_id)
1844 .await;
1845
1846 if refund.user_triggered {
1847 yield SpendOOBState::UserCanceledProcessing;
1848 }
1849
1850 let mut success = true;
1851
1852 for txid in refund.transaction_ids {
1853 debug!(
1854 target: LOG_CLIENT_MODULE_MINT,
1855 %txid,
1856 operation_id=%operation_id.fmt_short(),
1857 "Waiting for oob refund txid"
1858 );
1859 if client_ctx
1860 .transaction_updates(operation_id)
1861 .await
1862 .await_tx_accepted(txid)
1863 .await.is_err() {
1864 success = false;
1865 }
1866 }
1867
1868 debug!(
1869 target: LOG_CLIENT_MODULE_MINT,
1870 operation_id=%operation_id.fmt_short(),
1871 %success,
1872 "Done waiting for all refund oob txids"
1873 );
1874
1875 match (refund.user_triggered, success) {
1876 (true, true) => {
1877 yield SpendOOBState::UserCanceledSuccess;
1878 },
1879 (true, false) => {
1880 yield SpendOOBState::UserCanceledFailure;
1881 },
1882 (false, true) => {
1883 yield SpendOOBState::Refunded;
1884 },
1885 (false, false) => {
1886 yield SpendOOBState::Success;
1887 }
1888 }
1889 }
1890 }))
1891 }
1892
1893 async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
1894 let operation = self.client_ctx.get_operation(operation_id).await?;
1895
1896 if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
1897 bail!("Operation is not a mint operation");
1898 }
1899
1900 Ok(operation)
1901 }
1902
1903 async fn delete_spendable_note(
1904 client_ctx: &ClientContext<MintClientModule>,
1905 dbtx: &mut DatabaseTransaction<'_>,
1906 amount: Amount,
1907 note: &SpendableNote,
1908 ) {
1909 client_ctx
1910 .log_event(
1911 dbtx,
1912 NoteSpent {
1913 nonce: note.nonce(),
1914 },
1915 )
1916 .await;
1917 dbtx.remove_entry(&NoteKey {
1918 amount,
1919 nonce: note.nonce(),
1920 })
1921 .await
1922 .expect("Must deleted existing spendable note");
1923 }
1924
1925 pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> {
1926 let db = self.client_ctx.module_db().clone();
1927
1928 Ok(db
1929 .autocommit(
1930 |dbtx, _| {
1931 Box::pin(async {
1932 Ok::<DerivableSecret, anyhow::Error>(
1933 self.new_note_secret(amount, dbtx).await,
1934 )
1935 })
1936 },
1937 None,
1938 )
1939 .await?)
1940 }
1941
1942 pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
1945 self.client_ctx
1946 .module_db()
1947 .begin_transaction_nc()
1948 .await
1949 .get_value(&ReusedNoteIndices)
1950 .await
1951 .unwrap_or_default()
1952 .into_iter()
1953 .map(|(amount, note_idx)| {
1954 let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
1955 let (request, blind_nonce) =
1956 NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
1957 (amount, request, blind_nonce)
1958 })
1959 .collect()
1960 }
1961}
1962
1963pub fn spendable_notes_to_operation_id(
1964 spendable_selected_notes: &TieredMulti<SpendableNote>,
1965) -> OperationId {
1966 OperationId(
1967 spendable_selected_notes
1968 .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
1969 .to_byte_array(),
1970 )
1971}
1972
1973#[derive(Debug, Serialize, Deserialize, Clone)]
1974pub struct SpendOOBRefund {
1975 pub user_triggered: bool,
1976 pub transaction_ids: Vec<TransactionId>,
1977}
1978
1979#[apply(async_trait_maybe_send!)]
1982pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
1983 async fn select_notes(
1986 &self,
1987 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
1989 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
1990 requested_amount: Amount,
1991 fee_consensus: FeeConsensus,
1992 ) -> anyhow::Result<TieredMulti<Note>>;
1993}
1994
1995pub struct SelectNotesWithAtleastAmount;
2001
2002#[apply(async_trait_maybe_send!)]
2003impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
2004 async fn select_notes(
2005 &self,
2006 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2007 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2008 requested_amount: Amount,
2009 fee_consensus: FeeConsensus,
2010 ) -> anyhow::Result<TieredMulti<Note>> {
2011 Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
2012 }
2013}
2014
2015pub struct SelectNotesWithExactAmount;
2019
2020#[apply(async_trait_maybe_send!)]
2021impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
2022 async fn select_notes(
2023 &self,
2024 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
2025 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
2026 requested_amount: Amount,
2027 fee_consensus: FeeConsensus,
2028 ) -> anyhow::Result<TieredMulti<Note>> {
2029 let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
2030
2031 if notes.total_amount() != requested_amount {
2032 bail!(
2033 "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}",
2034 requested_amount,
2035 notes.total_amount()
2036 );
2037 }
2038
2039 Ok(notes)
2040 }
2041}
2042
2043async fn select_notes_from_stream<Note>(
2049 stream: impl futures::Stream<Item = (Amount, Note)>,
2050 requested_amount: Amount,
2051 fee_consensus: FeeConsensus,
2052) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
2053 if requested_amount == Amount::ZERO {
2054 return Ok(TieredMulti::default());
2055 }
2056 let mut stream = Box::pin(stream);
2057 let mut selected = vec![];
2058 let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
2063 let mut pending_amount = requested_amount;
2064 let mut previous_amount: Option<Amount> = None; loop {
2066 if let Some((note_amount, note)) = stream.next().await {
2067 assert!(
2068 previous_amount.is_none_or(|previous| previous >= note_amount),
2069 "notes are not sorted in descending order"
2070 );
2071 previous_amount = Some(note_amount);
2072
2073 if note_amount <= fee_consensus.fee(note_amount) {
2074 continue;
2075 }
2076
2077 match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2078 Ordering::Less => {
2079 pending_amount += fee_consensus.fee(note_amount);
2081 pending_amount -= note_amount;
2082 selected.push((note_amount, note));
2083 }
2084 Ordering::Greater => {
2085 last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2089 }
2090 Ordering::Equal => {
2091 selected.push((note_amount, note));
2093
2094 let notes: TieredMulti<Note> = selected.into_iter().collect();
2095
2096 assert!(
2097 notes.total_amount().msats
2098 >= requested_amount.msats
2099 + notes
2100 .iter()
2101 .map(|note| fee_consensus.fee(note.0))
2102 .sum::<Amount>()
2103 .msats
2104 );
2105
2106 return Ok(notes);
2107 }
2108 }
2109 } else {
2110 assert!(pending_amount > Amount::ZERO);
2111 if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2112 selected.truncate(checkpoint);
2115 selected.push((big_note_amount, big_note));
2117
2118 let notes: TieredMulti<Note> = selected.into_iter().collect();
2119
2120 assert!(
2121 notes.total_amount().msats
2122 >= requested_amount.msats
2123 + notes
2124 .iter()
2125 .map(|note| fee_consensus.fee(note.0))
2126 .sum::<Amount>()
2127 .msats
2128 );
2129
2130 return Ok(notes);
2132 }
2133
2134 let total_amount = requested_amount.saturating_sub(pending_amount);
2135 return Err(InsufficientBalanceError {
2137 requested_amount,
2138 total_amount,
2139 });
2140 }
2141 }
2142}
2143
2144#[derive(Debug, Clone, Error)]
2145pub struct InsufficientBalanceError {
2146 pub requested_amount: Amount,
2147 pub total_amount: Amount,
2148}
2149
2150impl std::fmt::Display for InsufficientBalanceError {
2151 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2152 write!(
2153 f,
2154 "Insufficient balance: requested {} but only {} available",
2155 self.requested_amount, self.total_amount
2156 )
2157 }
2158}
2159
2160#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2162enum MintRestoreStates {
2163 #[encodable_default]
2164 Default { variant: u64, bytes: Vec<u8> },
2165}
2166
2167#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2169pub struct MintRestoreStateMachine {
2170 operation_id: OperationId,
2171 state: MintRestoreStates,
2172}
2173
2174#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2175pub enum MintClientStateMachines {
2176 Output(MintOutputStateMachine),
2177 Input(MintInputStateMachine),
2178 OOB(MintOOBStateMachine),
2179 Restore(MintRestoreStateMachine),
2181}
2182
2183impl IntoDynInstance for MintClientStateMachines {
2184 type DynType = DynState;
2185
2186 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2187 DynState::from_typed(instance_id, self)
2188 }
2189}
2190
2191impl State for MintClientStateMachines {
2192 type ModuleContext = MintClientContext;
2193
2194 fn transitions(
2195 &self,
2196 context: &Self::ModuleContext,
2197 global_context: &DynGlobalClientContext,
2198 ) -> Vec<StateTransition<Self>> {
2199 match self {
2200 MintClientStateMachines::Output(issuance_state) => {
2201 sm_enum_variant_translation!(
2202 issuance_state.transitions(context, global_context),
2203 MintClientStateMachines::Output
2204 )
2205 }
2206 MintClientStateMachines::Input(redemption_state) => {
2207 sm_enum_variant_translation!(
2208 redemption_state.transitions(context, global_context),
2209 MintClientStateMachines::Input
2210 )
2211 }
2212 MintClientStateMachines::OOB(oob_state) => {
2213 sm_enum_variant_translation!(
2214 oob_state.transitions(context, global_context),
2215 MintClientStateMachines::OOB
2216 )
2217 }
2218 MintClientStateMachines::Restore(_) => {
2219 sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
2220 }
2221 }
2222 }
2223
2224 fn operation_id(&self) -> OperationId {
2225 match self {
2226 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
2227 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
2228 MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
2229 MintClientStateMachines::Restore(r) => r.operation_id,
2230 }
2231 }
2232}
2233
2234#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
2237pub struct SpendableNote {
2238 pub signature: tbs::Signature,
2239 pub spend_key: Keypair,
2240}
2241
2242impl fmt::Debug for SpendableNote {
2243 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2244 f.debug_struct("SpendableNote")
2245 .field("nonce", &self.nonce())
2246 .field("signature", &self.signature)
2247 .field("spend_key", &self.spend_key)
2248 .finish()
2249 }
2250}
2251impl fmt::Display for SpendableNote {
2252 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2253 self.nonce().fmt(f)
2254 }
2255}
2256
2257impl SpendableNote {
2258 pub fn nonce(&self) -> Nonce {
2259 Nonce(self.spend_key.public_key())
2260 }
2261
2262 fn note(&self) -> Note {
2263 Note {
2264 nonce: self.nonce(),
2265 signature: self.signature,
2266 }
2267 }
2268
2269 pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
2270 SpendableNoteUndecoded {
2271 signature: self
2272 .signature
2273 .consensus_encode_to_vec()
2274 .try_into()
2275 .expect("Encoded size always correct"),
2276 spend_key: self.spend_key,
2277 }
2278 }
2279}
2280
2281#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
2293pub struct SpendableNoteUndecoded {
2294 #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
2297 pub signature: [u8; 48],
2298 pub spend_key: Keypair,
2299}
2300
2301impl fmt::Display for SpendableNoteUndecoded {
2302 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2303 self.nonce().fmt(f)
2304 }
2305}
2306
2307impl fmt::Debug for SpendableNoteUndecoded {
2308 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2309 f.debug_struct("SpendableNote")
2310 .field("nonce", &self.nonce())
2311 .field("signature", &"[raw]")
2312 .field("spend_key", &self.spend_key)
2313 .finish()
2314 }
2315}
2316
2317impl SpendableNoteUndecoded {
2318 fn nonce(&self) -> Nonce {
2319 Nonce(self.spend_key.public_key())
2320 }
2321
2322 pub fn decode(self) -> anyhow::Result<SpendableNote> {
2323 Ok(SpendableNote {
2324 signature: Decodable::consensus_decode_partial_from_finite_reader(
2325 &mut self.signature.as_slice(),
2326 &ModuleRegistry::default(),
2327 )?,
2328 spend_key: self.spend_key,
2329 })
2330 }
2331}
2332
2333#[derive(
2339 Copy,
2340 Clone,
2341 Debug,
2342 Serialize,
2343 Deserialize,
2344 PartialEq,
2345 Eq,
2346 Encodable,
2347 Decodable,
2348 Default,
2349 PartialOrd,
2350 Ord,
2351)]
2352pub struct NoteIndex(u64);
2353
2354impl NoteIndex {
2355 pub fn next(self) -> Self {
2356 Self(self.0 + 1)
2357 }
2358
2359 fn prev(self) -> Option<Self> {
2360 self.0.checked_sub(0).map(Self)
2361 }
2362
2363 pub fn as_u64(self) -> u64 {
2364 self.0
2365 }
2366
2367 #[allow(unused)]
2371 pub fn from_u64(v: u64) -> Self {
2372 Self(v)
2373 }
2374
2375 pub fn advance(&mut self) {
2376 *self = self.next();
2377 }
2378}
2379
2380impl std::fmt::Display for NoteIndex {
2381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2382 self.0.fmt(f)
2383 }
2384}
2385
2386struct OOBSpendTag;
2387
2388impl sha256t::Tag for OOBSpendTag {
2389 fn engine() -> sha256::HashEngine {
2390 let mut engine = sha256::HashEngine::default();
2391 engine.input(b"oob-spend");
2392 engine
2393 }
2394}
2395
2396struct OOBReissueTag;
2397
2398impl sha256t::Tag for OOBReissueTag {
2399 fn engine() -> sha256::HashEngine {
2400 let mut engine = sha256::HashEngine::default();
2401 engine.input(b"oob-reissue");
2402 engine
2403 }
2404}
2405
2406pub fn represent_amount<K>(
2412 amount: Amount,
2413 current_denominations: &TieredCounts,
2414 tiers: &Tiered<K>,
2415 denomination_sets: u16,
2416 fee_consensus: &FeeConsensus,
2417) -> TieredCounts {
2418 let mut remaining_amount = amount;
2419 let mut denominations = TieredCounts::default();
2420
2421 for tier in tiers.tiers() {
2423 let notes = current_denominations.get(*tier);
2424 let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
2425 let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
2426
2427 let add_notes = min(possible_notes, missing_notes);
2428 denominations.inc(*tier, add_notes as usize);
2429 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
2430 }
2431
2432 for tier in tiers.tiers().rev() {
2434 let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
2435 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
2436 denominations.inc(*tier, res as usize);
2437 }
2438
2439 let represented: u64 = denominations
2440 .iter()
2441 .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
2442 .sum();
2443
2444 assert!(represented <= amount.msats);
2445 assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
2446
2447 denominations
2448}
2449
2450pub(crate) fn create_bundle_for_inputs(
2451 inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
2452 operation_id: OperationId,
2453) -> ClientInputBundle<MintInput, MintClientStateMachines> {
2454 let mut inputs = Vec::new();
2455 let mut input_states = Vec::new();
2456
2457 for (input, spendable_note) in inputs_and_notes {
2458 input_states.push((input.amounts.clone(), spendable_note));
2459 inputs.push(input);
2460 }
2461
2462 let input_sm = Arc::new(move |out_point_range: OutPointRange| {
2463 debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
2464
2465 vec![MintClientStateMachines::Input(MintInputStateMachine {
2466 common: MintInputCommon {
2467 operation_id,
2468 out_point_range,
2469 },
2470 state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
2471 notes: input_states
2472 .iter()
2473 .map(|(amounts, note)| (amounts.expect_only_bitcoin(), *note))
2474 .collect(),
2475 }),
2476 })]
2477 });
2478
2479 ClientInputBundle::new(
2480 inputs,
2481 vec![ClientInputSM {
2482 state_machines: input_sm,
2483 }],
2484 )
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489 use std::fmt::Display;
2490 use std::str::FromStr;
2491
2492 use bitcoin_hashes::Hash;
2493 use fedimint_core::base32::FEDIMINT_PREFIX;
2494 use fedimint_core::config::FederationId;
2495 use fedimint_core::encoding::Decodable;
2496 use fedimint_core::invite_code::InviteCode;
2497 use fedimint_core::module::registry::ModuleRegistry;
2498 use fedimint_core::{
2499 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
2500 };
2501 use fedimint_mint_common::config::FeeConsensus;
2502 use itertools::Itertools;
2503 use serde_json::json;
2504
2505 use crate::{
2506 MintOperationMetaVariant, OOBNotes, OOBNotesPart, SpendableNote, SpendableNoteUndecoded,
2507 represent_amount, select_notes_from_stream,
2508 };
2509
2510 #[test]
2511 fn represent_amount_targets_denomination_sets() {
2512 fn tiers(tiers: Vec<u64>) -> Tiered<()> {
2513 tiers
2514 .into_iter()
2515 .map(|tier| (Amount::from_sats(tier), ()))
2516 .collect()
2517 }
2518
2519 fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
2520 TieredCounts::from_iter(denominations)
2521 }
2522
2523 let starting = notes(vec![
2524 (Amount::from_sats(1), 1),
2525 (Amount::from_sats(2), 3),
2526 (Amount::from_sats(3), 2),
2527 ])
2528 .summary();
2529 let tiers = tiers(vec![1, 2, 3, 4]);
2530
2531 assert_eq!(
2533 represent_amount(
2534 Amount::from_sats(6),
2535 &starting,
2536 &tiers,
2537 3,
2538 &FeeConsensus::zero()
2539 ),
2540 denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
2541 );
2542
2543 assert_eq!(
2545 represent_amount(
2546 Amount::from_sats(6),
2547 &starting,
2548 &tiers,
2549 2,
2550 &FeeConsensus::zero()
2551 ),
2552 denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
2553 );
2554 }
2555
2556 #[test_log::test(tokio::test)]
2557 async fn select_notes_avg_test() {
2558 let max_amount = Amount::from_sats(1_000_000);
2559 let tiers = Tiered::gen_denominations(2, max_amount);
2560 let tiered = represent_amount::<()>(
2561 max_amount,
2562 &TieredCounts::default(),
2563 &tiers,
2564 3,
2565 &FeeConsensus::zero(),
2566 );
2567
2568 let mut total_notes = 0;
2569 for multiplier in 1..100 {
2570 let stream = reverse_sorted_note_stream(tiered.iter().collect());
2571 let select = select_notes_from_stream(
2572 stream,
2573 Amount::from_sats(multiplier * 1000),
2574 FeeConsensus::zero(),
2575 )
2576 .await;
2577 total_notes += select.unwrap().into_iter_items().count();
2578 }
2579 assert_eq!(total_notes / 100, 10);
2580 }
2581
2582 #[test_log::test(tokio::test)]
2583 async fn select_notes_returns_exact_amount_with_minimum_notes() {
2584 let f = || {
2585 reverse_sorted_note_stream(vec![
2586 (Amount::from_sats(1), 10),
2587 (Amount::from_sats(5), 10),
2588 (Amount::from_sats(20), 10),
2589 ])
2590 };
2591 assert_eq!(
2592 select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
2593 .await
2594 .unwrap(),
2595 notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
2596 );
2597 assert_eq!(
2598 select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
2599 .await
2600 .unwrap(),
2601 notes(vec![(Amount::from_sats(20), 1)])
2602 );
2603 }
2604
2605 #[test_log::test(tokio::test)]
2606 async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
2607 let stream = reverse_sorted_note_stream(vec![
2608 (Amount::from_sats(1), 1),
2609 (Amount::from_sats(5), 5),
2610 (Amount::from_sats(20), 5),
2611 ]);
2612 assert_eq!(
2613 select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
2614 .await
2615 .unwrap(),
2616 notes(vec![(Amount::from_sats(5), 2)])
2617 );
2618 }
2619
2620 #[test_log::test(tokio::test)]
2621 async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
2622 let stream = reverse_sorted_note_stream(vec![
2623 (Amount::from_sats(1), 3),
2624 (Amount::from_sats(5), 3),
2625 (Amount::from_sats(20), 2),
2626 ]);
2627 assert_eq!(
2628 select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
2629 .await
2630 .unwrap(),
2631 notes(vec![(Amount::from_sats(20), 2)])
2632 );
2633 }
2634
2635 #[test_log::test(tokio::test)]
2636 async fn select_notes_returns_error_if_amount_is_too_large() {
2637 let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
2638 let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
2639 .await
2640 .unwrap_err();
2641 assert_eq!(error.total_amount, Amount::from_sats(10));
2642 }
2643
2644 fn reverse_sorted_note_stream(
2645 notes: Vec<(Amount, usize)>,
2646 ) -> impl futures::Stream<Item = (Amount, String)> {
2647 futures::stream::iter(
2648 notes
2649 .into_iter()
2650 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2652 .sorted()
2653 .rev(),
2654 )
2655 }
2656
2657 fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
2658 notes
2659 .into_iter()
2660 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2661 .collect()
2662 }
2663
2664 #[test]
2665 fn decoding_empty_oob_notes_fails() {
2666 let empty_oob_notes =
2667 OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
2668 let oob_notes_string = empty_oob_notes.to_string();
2669
2670 let res = oob_notes_string.parse::<OOBNotes>();
2671
2672 assert!(res.is_err(), "An empty OOB notes string should not parse");
2673 }
2674
2675 fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
2676 where
2677 T: FromStr + Display + crate::Encodable + crate::Decodable,
2678 <T as FromStr>::Err: std::fmt::Debug,
2679 F: Fn(T),
2680 {
2681 let data_parsed = data.to_string().parse().expect("Deserialization failed");
2682
2683 assertions(data_parsed);
2684
2685 let data_parsed = crate::base32::encode_prefixed(FEDIMINT_PREFIX, &data)
2686 .parse()
2687 .expect("Deserialization failed");
2688
2689 assertions(data_parsed);
2690
2691 assertions(data);
2692 }
2693
2694 #[test]
2695 fn notes_encode_decode() {
2696 let federation_id_1 =
2697 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
2698 let federation_id_prefix_1 = federation_id_1.to_prefix();
2699 let federation_id_2 =
2700 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
2701 let federation_id_prefix_2 = federation_id_2.to_prefix();
2702
2703 let notes = vec![(
2704 Amount::from_sats(1),
2705 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
2706 )]
2707 .into_iter()
2708 .collect::<TieredMulti<_>>();
2709
2710 let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
2712 test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
2713 assert_eq!(oob_notes.notes(), ¬es);
2714 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2715 assert_eq!(oob_notes.federation_invite(), None);
2716 });
2717
2718 let invite = InviteCode::new(
2720 "wss://foo.bar".parse().unwrap(),
2721 PeerId::from(0),
2722 federation_id_1,
2723 None,
2724 );
2725 let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
2726 test_roundtrip_serialize_str(notes_invite, |oob_notes| {
2727 assert_eq!(oob_notes.notes(), ¬es);
2728 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2729 assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
2730 });
2731
2732 let notes_no_prefix = OOBNotes(vec![
2735 OOBNotesPart::Notes(notes.clone()),
2736 OOBNotesPart::Invite {
2737 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2738 federation_id: federation_id_1,
2739 },
2740 ]);
2741 test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
2742 assert_eq!(oob_notes.notes(), ¬es);
2743 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2744 });
2745
2746 let notes_inconsistent = OOBNotes(vec![
2748 OOBNotesPart::Notes(notes),
2749 OOBNotesPart::Invite {
2750 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2751 federation_id: federation_id_1,
2752 },
2753 OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
2754 ]);
2755 let notes_inconsistent_str = notes_inconsistent.to_string();
2756 assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
2757 }
2758
2759 #[test]
2760 fn spendable_note_undecoded_sanity() {
2761 #[allow(clippy::single_element_loop)]
2763 for note_hex in [
2764 "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
2765 ] {
2766 let note =
2767 SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
2768 let note_undecoded =
2769 SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
2770 .unwrap()
2771 .decode()
2772 .unwrap();
2773 assert_eq!(note, note_undecoded,);
2774 assert_eq!(
2775 serde_json::to_string(¬e).unwrap(),
2776 serde_json::to_string(¬e_undecoded).unwrap(),
2777 );
2778 }
2779 }
2780
2781 #[test]
2782 fn reissuance_meta_compatibility_02_03() {
2783 let dummy_outpoint = OutPoint {
2784 txid: TransactionId::all_zeros(),
2785 out_idx: 0,
2786 };
2787
2788 let old_meta_json = json!({
2789 "reissuance": {
2790 "out_point": dummy_outpoint
2791 }
2792 });
2793
2794 let old_meta: MintOperationMetaVariant =
2795 serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
2796 assert_eq!(
2797 old_meta,
2798 MintOperationMetaVariant::Reissuance {
2799 legacy_out_point: Some(dummy_outpoint),
2800 txid: None,
2801 out_point_indices: vec![],
2802 }
2803 );
2804
2805 let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
2806 legacy_out_point: None,
2807 txid: Some(dummy_outpoint.txid),
2808 out_point_indices: vec![0],
2809 })
2810 .expect("serializing always works");
2811 assert_eq!(
2812 new_meta_json,
2813 json!({
2814 "reissuance": {
2815 "txid": dummy_outpoint.txid,
2816 "out_point_indices": [dummy_outpoint.out_idx],
2817 }
2818 })
2819 );
2820 }
2821}