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::{ClientContext, ClientModule, IClientModule, OutPointRange};
54use fedimint_client_module::oplog::{OperationLogEntry, UpdateStreamOrOutcome};
55use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
56use fedimint_client_module::transaction::{
57 ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
58 ClientOutputSM, TransactionBuilder,
59};
60use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
61use fedimint_core::base32::FEDIMINT_PREFIX;
62use fedimint_core::config::{FederationId, FederationIdPrefix};
63use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
64use fedimint_core::db::{
65 AutocommitError, Database, DatabaseTransaction, DatabaseVersion,
66 IDatabaseTransactionOpsCoreTyped,
67};
68use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
69use fedimint_core::invite_code::InviteCode;
70use fedimint_core::module::registry::{ModuleDecoderRegistry, ModuleRegistry};
71use fedimint_core::module::{
72 ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
73};
74use fedimint_core::secp256k1::{All, Keypair, Secp256k1};
75use fedimint_core::util::{BoxFuture, BoxStream, NextOrPending, SafeUrl};
76use fedimint_core::{
77 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId, apply,
78 async_trait_maybe_send, base32, push_db_pair_items,
79};
80use fedimint_derive_secret::{ChildId, DerivableSecret};
81use fedimint_logging::LOG_CLIENT_MODULE_MINT;
82pub use fedimint_mint_common as common;
83use fedimint_mint_common::config::{FeeConsensus, MintClientConfig};
84pub use fedimint_mint_common::*;
85use futures::future::try_join_all;
86use futures::{StreamExt, pin_mut};
87use hex::ToHex;
88use input::MintInputStateCreatedBundle;
89use itertools::Itertools as _;
90use oob::MintOOBStatesCreatedMulti;
91use output::MintOutputStatesCreatedMulti;
92use serde::{Deserialize, Serialize};
93use strum::IntoEnumIterator;
94use tbs::AggregatePublicKey;
95use thiserror::Error;
96use tracing::{debug, warn};
97
98use crate::backup::EcashBackup;
99use crate::client_db::{
100 CancelledOOBSpendKey, CancelledOOBSpendKeyPrefix, NextECashNoteIndexKey,
101 NextECashNoteIndexKeyPrefix, NoteKey,
102};
103use crate::input::{MintInputCommon, MintInputStateMachine, MintInputStates};
104use crate::oob::{MintOOBStateMachine, MintOOBStates};
105use crate::output::{
106 MintOutputCommon, MintOutputStateMachine, MintOutputStates, NoteIssuanceRequest,
107};
108
109const MINT_E_CASH_TYPE_CHILD_ID: ChildId = ChildId(0);
110
111#[derive(Clone, Debug, Encodable, PartialEq, Eq)]
119pub struct OOBNotes(Vec<OOBNotesPart>);
120
121#[derive(Clone, Debug, Decodable, Encodable, PartialEq, Eq)]
124enum OOBNotesPart {
125 Notes(TieredMulti<SpendableNote>),
126 FederationIdPrefix(FederationIdPrefix),
127 Invite {
131 peer_apis: Vec<(PeerId, SafeUrl)>,
133 federation_id: FederationId,
134 },
135 ApiSecret(String),
136 #[encodable_default]
137 Default {
138 variant: u64,
139 bytes: Vec<u8>,
140 },
141}
142
143impl OOBNotes {
144 pub fn new(
145 federation_id_prefix: FederationIdPrefix,
146 notes: TieredMulti<SpendableNote>,
147 ) -> Self {
148 Self(vec![
149 OOBNotesPart::FederationIdPrefix(federation_id_prefix),
150 OOBNotesPart::Notes(notes),
151 ])
152 }
153
154 pub fn new_with_invite(notes: TieredMulti<SpendableNote>, invite: &InviteCode) -> Self {
155 let mut data = vec![
156 OOBNotesPart::FederationIdPrefix(invite.federation_id().to_prefix()),
159 OOBNotesPart::Notes(notes),
160 OOBNotesPart::Invite {
161 peer_apis: vec![(invite.peer(), invite.url())],
162 federation_id: invite.federation_id(),
163 },
164 ];
165 if let Some(api_secret) = invite.api_secret() {
166 data.push(OOBNotesPart::ApiSecret(api_secret));
167 }
168 Self(data)
169 }
170
171 pub fn federation_id_prefix(&self) -> FederationIdPrefix {
172 self.0
173 .iter()
174 .find_map(|data| match data {
175 OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
176 OOBNotesPart::Invite { federation_id, .. } => Some(federation_id.to_prefix()),
177 _ => None,
178 })
179 .expect("Invariant violated: OOBNotes does not contain a FederationIdPrefix")
180 }
181
182 pub fn notes(&self) -> &TieredMulti<SpendableNote> {
183 self.0
184 .iter()
185 .find_map(|data| match data {
186 OOBNotesPart::Notes(notes) => Some(notes),
187 _ => None,
188 })
189 .expect("Invariant violated: OOBNotes does not contain any notes")
190 }
191
192 pub fn notes_json(&self) -> Result<serde_json::Value, serde_json::Error> {
193 let mut notes_map = serde_json::Map::new();
194 for notes in &self.0 {
195 match notes {
196 OOBNotesPart::Notes(notes) => {
197 let notes_json = serde_json::to_value(notes)?;
198 notes_map.insert("notes".to_string(), notes_json);
199 }
200 OOBNotesPart::FederationIdPrefix(prefix) => {
201 notes_map.insert(
202 "federation_id_prefix".to_string(),
203 serde_json::to_value(prefix.to_string())?,
204 );
205 }
206 OOBNotesPart::Invite {
207 peer_apis,
208 federation_id,
209 } => {
210 let (peer_id, api) = peer_apis
211 .first()
212 .cloned()
213 .expect("Decoding makes sure peer_apis isn't empty");
214 notes_map.insert(
215 "invite".to_string(),
216 serde_json::to_value(InviteCode::new(
217 api,
218 peer_id,
219 *federation_id,
220 self.api_secret(),
221 ))?,
222 );
223 }
224 OOBNotesPart::ApiSecret(_) => { }
225 OOBNotesPart::Default { variant, bytes } => {
226 notes_map.insert(
227 format!("default_{variant}"),
228 serde_json::to_value(bytes.encode_hex::<String>())?,
229 );
230 }
231 }
232 }
233 Ok(serde_json::Value::Object(notes_map))
234 }
235
236 pub fn federation_invite(&self) -> Option<InviteCode> {
237 self.0.iter().find_map(|data| {
238 let OOBNotesPart::Invite {
239 peer_apis,
240 federation_id,
241 } = data
242 else {
243 return None;
244 };
245 let (peer_id, api) = peer_apis
246 .first()
247 .cloned()
248 .expect("Decoding makes sure peer_apis isn't empty");
249 Some(InviteCode::new(
250 api,
251 peer_id,
252 *federation_id,
253 self.api_secret(),
254 ))
255 })
256 }
257
258 fn api_secret(&self) -> Option<String> {
259 self.0.iter().find_map(|data| {
260 let OOBNotesPart::ApiSecret(api_secret) = data else {
261 return None;
262 };
263 Some(api_secret.clone())
264 })
265 }
266}
267
268impl Decodable for OOBNotes {
269 fn consensus_decode_partial<R: Read>(
270 r: &mut R,
271 _modules: &ModuleDecoderRegistry,
272 ) -> Result<Self, DecodeError> {
273 let inner =
274 Vec::<OOBNotesPart>::consensus_decode_partial(r, &ModuleDecoderRegistry::default())?;
275
276 if !inner
278 .iter()
279 .any(|data| matches!(data, OOBNotesPart::Notes(_)))
280 {
281 return Err(DecodeError::from_str(
282 "No e-cash notes were found in OOBNotes data",
283 ));
284 }
285
286 let maybe_federation_id_prefix = inner.iter().find_map(|data| match data {
287 OOBNotesPart::FederationIdPrefix(prefix) => Some(*prefix),
288 _ => None,
289 });
290
291 let maybe_invite = inner.iter().find_map(|data| match data {
292 OOBNotesPart::Invite {
293 federation_id,
294 peer_apis,
295 } => Some((federation_id, peer_apis)),
296 _ => None,
297 });
298
299 match (maybe_federation_id_prefix, maybe_invite) {
300 (Some(p), Some((ip, _))) => {
301 if p != ip.to_prefix() {
302 return Err(DecodeError::from_str(
303 "Inconsistent Federation ID provided in OOBNotes data",
304 ));
305 }
306 }
307 (None, None) => {
308 return Err(DecodeError::from_str(
309 "No Federation ID provided in OOBNotes data",
310 ));
311 }
312 _ => {}
313 }
314
315 if let Some((_, invite)) = maybe_invite
316 && invite.is_empty()
317 {
318 return Err(DecodeError::from_str("Invite didn't contain API endpoints"));
319 }
320
321 Ok(OOBNotes(inner))
322 }
323}
324
325const BASE64_URL_SAFE: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new(
326 &base64::alphabet::URL_SAFE,
327 base64::engine::general_purpose::PAD,
328);
329
330impl FromStr for OOBNotes {
331 type Err = anyhow::Error;
332
333 fn from_str(s: &str) -> Result<Self, Self::Err> {
335 let s: String = s.chars().filter(|&c| !c.is_whitespace()).collect();
336
337 let oob_notes_bytes = if let Ok(oob_notes_bytes) =
338 base32::decode_prefixed_bytes(FEDIMINT_PREFIX, &s)
339 {
340 oob_notes_bytes
341 } else if let Ok(oob_notes_bytes) = BASE64_URL_SAFE.decode(&s) {
342 oob_notes_bytes
343 } else if let Ok(oob_notes_bytes) = base64::engine::general_purpose::STANDARD.decode(&s) {
344 oob_notes_bytes
345 } else {
346 bail!("OOBNotes were not a well-formed base64(URL-safe) or base32 string");
347 };
348
349 let oob_notes =
350 OOBNotes::consensus_decode_whole(&oob_notes_bytes, &ModuleDecoderRegistry::default())?;
351
352 ensure!(!oob_notes.notes().is_empty(), "OOBNotes cannot be empty");
353
354 Ok(oob_notes)
355 }
356}
357
358impl Display for OOBNotes {
359 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
360 let bytes = Encodable::consensus_encode_to_vec(self);
361
362 f.write_str(&BASE64_URL_SAFE.encode(&bytes))
363 }
364}
365
366impl Serialize for OOBNotes {
367 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
368 where
369 S: serde::Serializer,
370 {
371 serializer.serialize_str(&self.to_string())
372 }
373}
374
375impl<'de> Deserialize<'de> for OOBNotes {
376 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
377 where
378 D: serde::Deserializer<'de>,
379 {
380 let s = String::deserialize(deserializer)?;
381 FromStr::from_str(&s).map_err(serde::de::Error::custom)
382 }
383}
384
385impl OOBNotes {
386 pub fn total_amount(&self) -> Amount {
388 self.notes().total_amount()
389 }
390}
391
392#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
395pub enum ReissueExternalNotesState {
396 Created,
399 Issuing,
402 Done,
404 Failed(String),
406}
407
408#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
411pub enum SpendOOBState {
412 Created,
414 UserCanceledProcessing,
417 UserCanceledSuccess,
420 UserCanceledFailure,
423 Success,
427 Refunded,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct MintOperationMeta {
435 pub variant: MintOperationMetaVariant,
436 pub amount: Amount,
437 pub extra_meta: serde_json::Value,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
441#[serde(rename_all = "snake_case")]
442pub enum MintOperationMetaVariant {
443 Reissuance {
447 #[serde(skip_serializing, default, rename = "out_point")]
449 legacy_out_point: Option<OutPoint>,
450 #[serde(default)]
452 txid: Option<TransactionId>,
453 #[serde(default)]
455 out_point_indices: Vec<u64>,
456 },
457 SpendOOB {
458 requested_amount: Amount,
459 oob_notes: OOBNotes,
460 },
461}
462
463#[derive(Debug, Clone)]
464pub struct MintClientInit;
465
466impl ModuleInit for MintClientInit {
467 type Common = MintCommonInit;
468
469 async fn dump_database(
470 &self,
471 dbtx: &mut DatabaseTransaction<'_>,
472 prefix_names: Vec<String>,
473 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
474 let mut mint_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
475 BTreeMap::new();
476 let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
477 prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
478 });
479
480 for table in filtered_prefixes {
481 match table {
482 DbKeyPrefix::Note => {
483 push_db_pair_items!(
484 dbtx,
485 NoteKeyPrefix,
486 NoteKey,
487 SpendableNoteUndecoded,
488 mint_client_items,
489 "Notes"
490 );
491 }
492 DbKeyPrefix::NextECashNoteIndex => {
493 push_db_pair_items!(
494 dbtx,
495 NextECashNoteIndexKeyPrefix,
496 NextECashNoteIndexKey,
497 u64,
498 mint_client_items,
499 "NextECashNoteIndex"
500 );
501 }
502 DbKeyPrefix::CancelledOOBSpend => {
503 push_db_pair_items!(
504 dbtx,
505 CancelledOOBSpendKeyPrefix,
506 CancelledOOBSpendKey,
507 (),
508 mint_client_items,
509 "CancelledOOBSpendKey"
510 );
511 }
512 DbKeyPrefix::RecoveryFinalized => {
513 if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await {
514 mint_client_items.insert("RecoveryFinalized".to_string(), Box::new(val));
515 }
516 }
517 DbKeyPrefix::RecoveryState
518 | DbKeyPrefix::ReusedNoteIndices
519 | DbKeyPrefix::ExternalReservedStart
520 | DbKeyPrefix::CoreInternalReservedStart
521 | DbKeyPrefix::CoreInternalReservedEnd => {}
522 }
523 }
524
525 Box::new(mint_client_items.into_iter())
526 }
527}
528
529#[apply(async_trait_maybe_send!)]
530impl ClientModuleInit for MintClientInit {
531 type Module = MintClientModule;
532
533 fn supported_api_versions(&self) -> MultiApiVersion {
534 MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
535 .expect("no version conflicts")
536 }
537
538 async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
539 Ok(MintClientModule {
540 federation_id: *args.federation_id(),
541 cfg: args.cfg().clone(),
542 secret: args.module_root_secret().clone(),
543 secp: Secp256k1::new(),
544 notifier: args.notifier().clone(),
545 client_ctx: args.context(),
546 })
547 }
548
549 async fn recover(
550 &self,
551 args: &ClientModuleRecoverArgs<Self>,
552 snapshot: Option<&<Self::Module as ClientModule>::Backup>,
553 ) -> anyhow::Result<()> {
554 args.recover_from_history::<MintRecovery>(self, snapshot)
555 .await
556 }
557
558 fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
559 let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = BTreeMap::new();
560 migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
561 Box::pin(migrate_to_v1(dbtx))
562 });
563 migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
564 Box::pin(async { migrate_state(active_states, inactive_states, migrate_state_to_v2) })
565 });
566
567 migrations
568 }
569
570 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
571 Some(
572 DbKeyPrefix::iter()
573 .map(|p| p as u8)
574 .chain(
575 DbKeyPrefix::ExternalReservedStart as u8
576 ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
577 )
578 .collect(),
579 )
580 }
581}
582
583#[derive(Debug)]
609pub struct MintClientModule {
610 federation_id: FederationId,
611 cfg: MintClientConfig,
612 secret: DerivableSecret,
613 secp: Secp256k1<All>,
614 notifier: ModuleNotifier<MintClientStateMachines>,
615 pub client_ctx: ClientContext<Self>,
616}
617
618#[derive(Debug, Clone)]
620pub struct MintClientContext {
621 pub client_ctx: ClientContext<MintClientModule>,
622 pub mint_decoder: Decoder,
623 pub tbs_pks: Tiered<AggregatePublicKey>,
624 pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>,
625 pub secret: DerivableSecret,
626 pub module_db: Database,
629}
630
631impl MintClientContext {
632 fn await_cancel_oob_payment(&self, operation_id: OperationId) -> BoxFuture<'static, ()> {
633 let db = self.module_db.clone();
634 Box::pin(async move {
635 db.wait_key_exists(&CancelledOOBSpendKey(operation_id))
636 .await;
637 })
638 }
639}
640
641impl Context for MintClientContext {
642 const KIND: Option<ModuleKind> = Some(KIND);
643}
644
645#[apply(async_trait_maybe_send!)]
646impl ClientModule for MintClientModule {
647 type Init = MintClientInit;
648 type Common = MintModuleTypes;
649 type Backup = EcashBackup;
650 type ModuleStateMachineContext = MintClientContext;
651 type States = MintClientStateMachines;
652
653 fn context(&self) -> Self::ModuleStateMachineContext {
654 MintClientContext {
655 client_ctx: self.client_ctx.clone(),
656 mint_decoder: self.decoder(),
657 tbs_pks: self.cfg.tbs_pks.clone(),
658 peer_tbs_pks: self.cfg.peer_tbs_pks.clone(),
659 secret: self.secret.clone(),
660 module_db: self.client_ctx.module_db().clone(),
661 }
662 }
663
664 fn input_fee(
665 &self,
666 amount: Amount,
667 _input: &<Self::Common as ModuleCommon>::Input,
668 ) -> Option<Amount> {
669 Some(self.cfg.fee_consensus.fee(amount))
670 }
671
672 fn output_fee(
673 &self,
674 amount: Amount,
675 _output: &<Self::Common as ModuleCommon>::Output,
676 ) -> Option<Amount> {
677 Some(self.cfg.fee_consensus.fee(amount))
678 }
679
680 #[cfg(feature = "cli")]
681 async fn handle_cli_command(
682 &self,
683 args: &[std::ffi::OsString],
684 ) -> anyhow::Result<serde_json::Value> {
685 cli::handle_cli_command(self, args).await
686 }
687
688 fn supports_backup(&self) -> bool {
689 true
690 }
691
692 async fn backup(&self) -> anyhow::Result<EcashBackup> {
693 self.client_ctx
694 .module_db()
695 .autocommit(
696 |dbtx_ctx, _| {
697 Box::pin(async { self.prepare_plaintext_ecash_backup(dbtx_ctx).await })
698 },
699 None,
700 )
701 .await
702 .map_err(|e| match e {
703 AutocommitError::ClosureError { error, .. } => error,
704 AutocommitError::CommitFailed { last_error, .. } => {
705 anyhow!("Commit to DB failed: {last_error}")
706 }
707 })
708 }
709
710 fn supports_being_primary(&self) -> bool {
711 true
712 }
713
714 async fn create_final_inputs_and_outputs(
715 &self,
716 dbtx: &mut DatabaseTransaction<'_>,
717 operation_id: OperationId,
718 mut input_amount: Amount,
719 mut output_amount: Amount,
720 ) -> anyhow::Result<(
721 ClientInputBundle<MintInput, MintClientStateMachines>,
722 ClientOutputBundle<MintOutput, MintClientStateMachines>,
723 )> {
724 let consolidation_inputs = self.consolidate_notes(dbtx).await?;
725
726 input_amount += consolidation_inputs
727 .iter()
728 .map(|input| input.0.amount)
729 .sum();
730
731 output_amount += consolidation_inputs
732 .iter()
733 .map(|input| self.cfg.fee_consensus.fee(input.0.amount))
734 .sum();
735
736 let additional_inputs = self
737 .create_sufficient_input(dbtx, output_amount.saturating_sub(input_amount))
738 .await?;
739
740 input_amount += additional_inputs.iter().map(|input| input.0.amount).sum();
741
742 output_amount += additional_inputs
743 .iter()
744 .map(|input| self.cfg.fee_consensus.fee(input.0.amount))
745 .sum();
746
747 let outputs = self
748 .create_output(
749 dbtx,
750 operation_id,
751 2,
752 input_amount.saturating_sub(output_amount),
753 )
754 .await;
755
756 Ok((
757 create_bundle_for_inputs(
758 [consolidation_inputs, additional_inputs].concat(),
759 operation_id,
760 ),
761 outputs,
762 ))
763 }
764
765 async fn await_primary_module_output(
766 &self,
767 operation_id: OperationId,
768 out_point: OutPoint,
769 ) -> anyhow::Result<()> {
770 self.await_output_finalized(operation_id, out_point).await
771 }
772
773 async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amount {
774 self.get_note_counts_by_denomination(dbtx)
775 .await
776 .total_amount()
777 }
778
779 async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
780 Box::pin(
781 self.notifier
782 .subscribe_all_operations()
783 .filter_map(|state| async move {
784 #[allow(deprecated)]
785 match state {
786 MintClientStateMachines::Output(MintOutputStateMachine {
787 state: MintOutputStates::Succeeded(_),
788 ..
789 })
790 | MintClientStateMachines::Input(MintInputStateMachine {
791 state: MintInputStates::Created(_) | MintInputStates::CreatedBundle(_),
792 ..
793 })
794 | MintClientStateMachines::OOB(MintOOBStateMachine {
795 state: MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_),
796 ..
797 }) => Some(()),
798 MintClientStateMachines::Output(MintOutputStateMachine {
801 state:
802 MintOutputStates::Created(_)
803 | MintOutputStates::CreatedMulti(_)
804 | MintOutputStates::Failed(_)
805 | MintOutputStates::Aborted(_),
806 ..
807 })
808 | MintClientStateMachines::Input(MintInputStateMachine {
809 state:
810 MintInputStates::Error(_)
811 | MintInputStates::Success(_)
812 | MintInputStates::Refund(_)
813 | MintInputStates::RefundedBundle(_)
814 | MintInputStates::RefundedPerNote(_)
815 | MintInputStates::RefundSuccess(_),
816 ..
817 })
818 | MintClientStateMachines::OOB(MintOOBStateMachine {
819 state:
820 MintOOBStates::TimeoutRefund(_)
821 | MintOOBStates::UserRefund(_)
822 | MintOOBStates::UserRefundMulti(_),
823 ..
824 })
825 | MintClientStateMachines::Restore(_) => None,
826 }
827 }),
828 )
829 }
830
831 async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
832 let balance = ClientModule::get_balance(self, dbtx).await;
833 if Amount::from_sats(0) < balance {
834 bail!("Outstanding balance: {balance}");
835 }
836
837 if !self.client_ctx.get_own_active_states().await.is_empty() {
838 bail!("Pending operations")
839 }
840 Ok(())
841 }
842 async fn handle_rpc(
843 &self,
844 method: String,
845 request: serde_json::Value,
846 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
847 Box::pin(try_stream! {
848 match method.as_str() {
849 "reissue_external_notes" => {
850 let req: ReissueExternalNotesRequest = serde_json::from_value(request)?;
851 let result = self.reissue_external_notes(req.oob_notes, req.extra_meta).await?;
852 yield serde_json::to_value(result)?;
853 }
854 "subscribe_reissue_external_notes" => {
855 let req: SubscribeReissueExternalNotesRequest = serde_json::from_value(request)?;
856 let stream = self.subscribe_reissue_external_notes(req.operation_id).await?;
857 for await state in stream.into_stream() {
858 yield serde_json::to_value(state)?;
859 }
860 }
861 "spend_notes" => {
862 let req: SpendNotesRequest = serde_json::from_value(request)?;
863 let result = self.spend_notes_with_selector(
864 &SelectNotesWithExactAmount,
865 req.amount,
866 req.try_cancel_after,
867 req.include_invite,
868 req.extra_meta
869 ).await?;
870 yield serde_json::to_value(result)?;
871 }
872 "spend_notes_expert" => {
873 let req: SpendNotesExpertRequest = serde_json::from_value(request)?;
874 let result = self.spend_notes_with_selector(
875 &SelectNotesWithAtleastAmount,
876 req.min_amount,
877 req.try_cancel_after,
878 req.include_invite,
879 req.extra_meta
880 ).await?;
881 yield serde_json::to_value(result)?;
882 }
883 "validate_notes" => {
884 let req: ValidateNotesRequest = serde_json::from_value(request)?;
885 let result = self.validate_notes(&req.oob_notes)?;
886 yield serde_json::to_value(result)?;
887 }
888 "try_cancel_spend_notes" => {
889 let req: TryCancelSpendNotesRequest = serde_json::from_value(request)?;
890 let result = self.try_cancel_spend_notes(req.operation_id).await;
891 yield serde_json::to_value(result)?;
892 }
893 "subscribe_spend_notes" => {
894 let req: SubscribeSpendNotesRequest = serde_json::from_value(request)?;
895 let stream = self.subscribe_spend_notes(req.operation_id).await?;
896 for await state in stream.into_stream() {
897 yield serde_json::to_value(state)?;
898 }
899 }
900 "await_spend_oob_refund" => {
901 let req: AwaitSpendOobRefundRequest = serde_json::from_value(request)?;
902 let value = self.await_spend_oob_refund(req.operation_id).await;
903 yield serde_json::to_value(value)?;
904 }
905 "note_counts_by_denomination" => {
906 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
907 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
908 yield serde_json::to_value(note_counts)?;
909 }
910 _ => {
911 Err(anyhow::format_err!("Unknown method: {}", method))?;
912 unreachable!()
913 },
914 }
915 })
916 }
917}
918
919#[derive(Deserialize)]
920struct ReissueExternalNotesRequest {
921 oob_notes: OOBNotes,
922 extra_meta: serde_json::Value,
923}
924
925#[derive(Deserialize)]
926struct SubscribeReissueExternalNotesRequest {
927 operation_id: OperationId,
928}
929
930#[derive(Deserialize)]
933struct SpendNotesExpertRequest {
934 min_amount: Amount,
935 try_cancel_after: Duration,
936 include_invite: bool,
937 extra_meta: serde_json::Value,
938}
939
940#[derive(Deserialize)]
941struct SpendNotesRequest {
942 amount: Amount,
943 try_cancel_after: Duration,
944 include_invite: bool,
945 extra_meta: serde_json::Value,
946}
947
948#[derive(Deserialize)]
949struct ValidateNotesRequest {
950 oob_notes: OOBNotes,
951}
952
953#[derive(Deserialize)]
954struct TryCancelSpendNotesRequest {
955 operation_id: OperationId,
956}
957
958#[derive(Deserialize)]
959struct SubscribeSpendNotesRequest {
960 operation_id: OperationId,
961}
962
963#[derive(Deserialize)]
964struct AwaitSpendOobRefundRequest {
965 operation_id: OperationId,
966}
967
968#[derive(thiserror::Error, Debug, Clone)]
969pub enum ReissueExternalNotesError {
970 #[error("Federation ID does not match")]
971 WrongFederationId,
972 #[error("We already reissued these notes")]
973 AlreadyReissued,
974}
975
976impl MintClientModule {
977 async fn create_sufficient_input(
978 &self,
979 dbtx: &mut DatabaseTransaction<'_>,
980 min_amount: Amount,
981 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
982 if min_amount == Amount::ZERO {
983 return Ok(vec![]);
984 }
985
986 let selected_notes = Self::select_notes(
987 dbtx,
988 &SelectNotesWithAtleastAmount,
989 min_amount,
990 self.cfg.fee_consensus.clone(),
991 )
992 .await?;
993
994 for (amount, note) in selected_notes.iter_items() {
995 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as sufficient input to fund a tx");
996 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
997 }
998
999 let inputs = self.create_input_from_notes(selected_notes)?;
1000
1001 assert!(!inputs.is_empty());
1002
1003 Ok(inputs)
1004 }
1005
1006 #[deprecated(
1008 since = "0.5.0",
1009 note = "Use `get_note_counts_by_denomination` instead"
1010 )]
1011 pub async fn get_notes_tier_counts(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1012 self.get_note_counts_by_denomination(dbtx).await
1013 }
1014
1015 pub async fn get_available_notes_by_tier_counts(
1019 &self,
1020 dbtx: &mut DatabaseTransaction<'_>,
1021 counts: TieredCounts,
1022 ) -> (TieredMulti<SpendableNoteUndecoded>, TieredCounts) {
1023 dbtx.find_by_prefix(&NoteKeyPrefix)
1024 .await
1025 .fold(
1026 (TieredMulti::<SpendableNoteUndecoded>::default(), counts),
1027 |(mut notes, mut counts), (key, note)| async move {
1028 let amount = key.amount;
1029 if 0 < counts.get(amount) {
1030 counts.dec(amount);
1031 notes.push(amount, note);
1032 }
1033
1034 (notes, counts)
1035 },
1036 )
1037 .await
1038 }
1039
1040 pub async fn create_output(
1045 &self,
1046 dbtx: &mut DatabaseTransaction<'_>,
1047 operation_id: OperationId,
1048 notes_per_denomination: u16,
1049 exact_amount: Amount,
1050 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1051 if exact_amount == Amount::ZERO {
1052 return ClientOutputBundle::new(vec![], vec![]);
1053 }
1054
1055 let denominations = represent_amount(
1056 exact_amount,
1057 &self.get_note_counts_by_denomination(dbtx).await,
1058 &self.cfg.tbs_pks,
1059 notes_per_denomination,
1060 &self.cfg.fee_consensus,
1061 );
1062
1063 let mut outputs = Vec::new();
1064 let mut issuance_requests = Vec::new();
1065
1066 for (amount, num) in denominations.iter() {
1067 for _ in 0..num {
1068 let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await;
1069
1070 debug!(
1071 %amount,
1072 "Generated issuance request"
1073 );
1074
1075 outputs.push(ClientOutput {
1076 output: MintOutput::new_v0(amount, blind_nonce),
1077 amount,
1078 });
1079
1080 issuance_requests.push((amount, issuance_request));
1081 }
1082 }
1083
1084 let state_generator = Arc::new(move |out_point_range: OutPointRange| {
1085 assert_eq!(out_point_range.count(), issuance_requests.len());
1086 vec![MintClientStateMachines::Output(MintOutputStateMachine {
1087 common: MintOutputCommon {
1088 operation_id,
1089 out_point_range,
1090 },
1091 state: MintOutputStates::CreatedMulti(MintOutputStatesCreatedMulti {
1092 issuance_requests: out_point_range
1093 .into_iter()
1094 .map(|out_point| out_point.out_idx)
1095 .zip(issuance_requests.clone())
1096 .collect(),
1097 }),
1098 })]
1099 });
1100
1101 ClientOutputBundle::new(
1102 outputs,
1103 vec![ClientOutputSM {
1104 state_machines: state_generator,
1105 }],
1106 )
1107 }
1108
1109 pub async fn get_note_counts_by_denomination(
1111 &self,
1112 dbtx: &mut DatabaseTransaction<'_>,
1113 ) -> TieredCounts {
1114 dbtx.find_by_prefix(&NoteKeyPrefix)
1115 .await
1116 .fold(
1117 TieredCounts::default(),
1118 |mut acc, (key, _note)| async move {
1119 acc.inc(key.amount, 1);
1120 acc
1121 },
1122 )
1123 .await
1124 }
1125
1126 #[deprecated(
1128 since = "0.5.0",
1129 note = "Use `get_note_counts_by_denomination` instead"
1130 )]
1131 pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1132 self.get_note_counts_by_denomination(dbtx).await
1133 }
1134
1135 pub async fn await_output_finalized(
1139 &self,
1140 operation_id: OperationId,
1141 out_point: OutPoint,
1142 ) -> anyhow::Result<()> {
1143 let stream = self
1144 .notifier
1145 .subscribe(operation_id)
1146 .await
1147 .filter_map(|state| async {
1148 let MintClientStateMachines::Output(state) = state else {
1149 return None;
1150 };
1151
1152 if state.common.txid() != out_point.txid
1153 || !state
1154 .common
1155 .out_point_range
1156 .out_idx_iter()
1157 .contains(&out_point.out_idx)
1158 {
1159 return None;
1160 }
1161
1162 match state.state {
1163 MintOutputStates::Succeeded(_) => Some(Ok(())),
1164 MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
1165 MintOutputStates::Failed(failed) => Some(Err(anyhow!(
1166 "Failed to finalize transaction: {}",
1167 failed.error
1168 ))),
1169 MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1170 }
1171 });
1172 pin_mut!(stream);
1173
1174 stream.next_or_pending().await
1175 }
1176
1177 pub async fn consolidate_notes(
1184 &self,
1185 dbtx: &mut DatabaseTransaction<'_>,
1186 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1187 const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1190 const MIN_NOTES_PER_TIER: usize = 4;
1192 const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1195 #[allow(clippy::assertions_on_constants)]
1197 {
1198 assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1199 }
1200
1201 let counts = self.get_note_counts_by_denomination(dbtx).await;
1202
1203 let should_consolidate = counts
1204 .iter()
1205 .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1206
1207 if !should_consolidate {
1208 return Ok(vec![]);
1209 }
1210
1211 let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1212
1213 let excessive_counts: TieredCounts = counts
1214 .iter()
1215 .map(|(amount, count)| {
1216 let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1217
1218 max_count -= take;
1219 (amount, take)
1220 })
1221 .collect();
1222
1223 let (selected_notes, unavailable) = self
1224 .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1225 .await;
1226
1227 debug_assert!(
1228 unavailable.is_empty(),
1229 "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1230 );
1231
1232 if !selected_notes.is_empty() {
1233 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");
1234 }
1235
1236 let mut selected_notes_decoded = vec![];
1237 for (amount, note) in selected_notes.iter_items() {
1238 let spendable_note_decoded = note.decode()?;
1239 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1240 Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1241 .await;
1242 selected_notes_decoded.push((amount, spendable_note_decoded));
1243 }
1244
1245 self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1246 }
1247
1248 #[allow(clippy::type_complexity)]
1250 pub fn create_input_from_notes(
1251 &self,
1252 notes: TieredMulti<SpendableNote>,
1253 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1254 let mut inputs_and_notes = Vec::new();
1255
1256 for (amount, spendable_note) in notes.into_iter_items() {
1257 let key = self
1258 .cfg
1259 .tbs_pks
1260 .get(amount)
1261 .ok_or(anyhow!("Invalid amount tier: {amount}"))?;
1262
1263 let note = spendable_note.note();
1264
1265 if !note.verify(*key) {
1266 bail!("Invalid note");
1267 }
1268
1269 inputs_and_notes.push((
1270 ClientInput {
1271 input: MintInput::new_v0(amount, note),
1272 keys: vec![spendable_note.spend_key],
1273 amount,
1274 },
1275 spendable_note,
1276 ));
1277 }
1278
1279 Ok(inputs_and_notes)
1280 }
1281
1282 async fn spend_notes_oob(
1283 &self,
1284 dbtx: &mut DatabaseTransaction<'_>,
1285 notes_selector: &impl NotesSelector,
1286 amount: Amount,
1287 try_cancel_after: Duration,
1288 ) -> anyhow::Result<(
1289 OperationId,
1290 Vec<MintClientStateMachines>,
1291 TieredMulti<SpendableNote>,
1292 )> {
1293 ensure!(
1294 amount > Amount::ZERO,
1295 "zero-amount out-of-band spends are not supported"
1296 );
1297
1298 let selected_notes =
1299 Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1300
1301 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1302
1303 for (amount, note) in selected_notes.iter_items() {
1304 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1305 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1306 }
1307
1308 let state_machines = vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1309 operation_id,
1310 state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1311 spendable_notes: selected_notes.clone().into_iter_items().collect(),
1312 timeout: fedimint_core::time::now() + try_cancel_after,
1313 }),
1314 })];
1315
1316 Ok((operation_id, state_machines, selected_notes))
1317 }
1318
1319 pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1320 Box::pin(
1321 self.notifier
1322 .subscribe(operation_id)
1323 .await
1324 .filter_map(|state| async {
1325 let MintClientStateMachines::OOB(state) = state else {
1326 return None;
1327 };
1328
1329 match state.state {
1330 MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1331 user_triggered: false,
1332 transaction_ids: vec![refund.refund_txid],
1333 }),
1334 MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1335 user_triggered: true,
1336 transaction_ids: vec![refund.refund_txid],
1337 }),
1338 MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1339 user_triggered: true,
1340 transaction_ids: vec![refund.refund_txid],
1341 }),
1342 MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1343 }
1344 }),
1345 )
1346 .next_or_pending()
1347 .await
1348 }
1349
1350 async fn select_notes(
1352 dbtx: &mut DatabaseTransaction<'_>,
1353 notes_selector: &impl NotesSelector,
1354 requested_amount: Amount,
1355 fee_consensus: FeeConsensus,
1356 ) -> anyhow::Result<TieredMulti<SpendableNote>> {
1357 let note_stream = dbtx
1358 .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1359 .await
1360 .map(|(key, note)| (key.amount, note));
1361
1362 notes_selector
1363 .select_notes(note_stream, requested_amount, fee_consensus)
1364 .await?
1365 .into_iter_items()
1366 .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1367 .collect::<anyhow::Result<TieredMulti<_>>>()
1368 }
1369
1370 async fn get_all_spendable_notes(
1371 dbtx: &mut DatabaseTransaction<'_>,
1372 ) -> TieredMulti<SpendableNoteUndecoded> {
1373 (dbtx
1374 .find_by_prefix(&NoteKeyPrefix)
1375 .await
1376 .map(|(key, note)| (key.amount, note))
1377 .collect::<Vec<_>>()
1378 .await)
1379 .into_iter()
1380 .collect()
1381 }
1382
1383 async fn get_next_note_index(
1384 &self,
1385 dbtx: &mut DatabaseTransaction<'_>,
1386 amount: Amount,
1387 ) -> NoteIndex {
1388 NoteIndex(
1389 dbtx.get_value(&NextECashNoteIndexKey(amount))
1390 .await
1391 .unwrap_or(0),
1392 )
1393 }
1394
1395 pub fn new_note_secret_static(
1411 secret: &DerivableSecret,
1412 amount: Amount,
1413 note_idx: NoteIndex,
1414 ) -> DerivableSecret {
1415 assert_eq!(secret.level(), 2);
1416 debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1417 secret
1418 .child_key(MINT_E_CASH_TYPE_CHILD_ID) .child_key(ChildId(note_idx.as_u64()))
1420 .child_key(ChildId(amount.msats))
1421 }
1422
1423 async fn new_note_secret(
1427 &self,
1428 amount: Amount,
1429 dbtx: &mut DatabaseTransaction<'_>,
1430 ) -> DerivableSecret {
1431 let new_idx = self.get_next_note_index(dbtx, amount).await;
1432 dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1433 .await;
1434 Self::new_note_secret_static(&self.secret, amount, new_idx)
1435 }
1436
1437 pub async fn new_ecash_note(
1438 &self,
1439 amount: Amount,
1440 dbtx: &mut DatabaseTransaction<'_>,
1441 ) -> (NoteIssuanceRequest, BlindNonce) {
1442 let secret = self.new_note_secret(amount, dbtx).await;
1443 NoteIssuanceRequest::new(&self.secp, &secret)
1444 }
1445
1446 pub async fn reissue_external_notes<M: Serialize + Send>(
1451 &self,
1452 oob_notes: OOBNotes,
1453 extra_meta: M,
1454 ) -> anyhow::Result<OperationId> {
1455 let notes = oob_notes.notes().clone();
1456 let federation_id_prefix = oob_notes.federation_id_prefix();
1457
1458 ensure!(
1459 notes.total_amount() > Amount::ZERO,
1460 "Reissuing zero-amount e-cash isn't supported"
1461 );
1462
1463 if federation_id_prefix != self.federation_id.to_prefix() {
1464 bail!(ReissueExternalNotesError::WrongFederationId);
1465 }
1466
1467 let operation_id = OperationId(
1468 notes
1469 .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
1470 .to_byte_array(),
1471 );
1472
1473 let amount = notes.total_amount();
1474 let mint_inputs = self.create_input_from_notes(notes)?;
1475
1476 let tx = TransactionBuilder::new().with_inputs(
1477 self.client_ctx
1478 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
1479 );
1480
1481 let extra_meta = serde_json::to_value(extra_meta)
1482 .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
1483 let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
1484 variant: MintOperationMetaVariant::Reissuance {
1485 legacy_out_point: None,
1486 txid: Some(change_range.txid()),
1487 out_point_indices: change_range
1488 .into_iter()
1489 .map(|out_point| out_point.out_idx)
1490 .collect(),
1491 },
1492 amount,
1493 extra_meta: extra_meta.clone(),
1494 };
1495
1496 self.client_ctx
1497 .finalize_and_submit_transaction(
1498 operation_id,
1499 MintCommonInit::KIND.as_str(),
1500 operation_meta_gen,
1501 tx,
1502 )
1503 .await
1504 .context(ReissueExternalNotesError::AlreadyReissued)?;
1505 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1506 self.client_ctx
1507 .log_event(&mut dbtx, OOBNotesReissued { amount })
1508 .await;
1509 dbtx.commit_tx().await;
1510
1511 Ok(operation_id)
1512 }
1513
1514 pub async fn subscribe_reissue_external_notes(
1517 &self,
1518 operation_id: OperationId,
1519 ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
1520 let operation = self.mint_operation(operation_id).await?;
1521 let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
1522 MintOperationMetaVariant::Reissuance {
1523 legacy_out_point,
1524 txid,
1525 out_point_indices,
1526 } => {
1527 let txid = txid
1530 .or(legacy_out_point.map(|out_point| out_point.txid))
1531 .context("Empty reissuance not permitted, this should never happen")?;
1532
1533 let out_points = out_point_indices
1534 .into_iter()
1535 .map(|out_idx| OutPoint { txid, out_idx })
1536 .chain(legacy_out_point)
1537 .collect::<Vec<_>>();
1538
1539 (txid, out_points)
1540 }
1541 MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"),
1542 };
1543
1544 let client_ctx = self.client_ctx.clone();
1545
1546 Ok(self.client_ctx.outcome_or_updates(operation, operation_id, move || {
1547 stream! {
1548 yield ReissueExternalNotesState::Created;
1549
1550 match client_ctx
1551 .transaction_updates(operation_id)
1552 .await
1553 .await_tx_accepted(txid)
1554 .await
1555 {
1556 Ok(()) => {
1557 yield ReissueExternalNotesState::Issuing;
1558 }
1559 Err(e) => {
1560 yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
1561 return;
1562 }
1563 }
1564
1565 for out_point in out_points {
1566 if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
1567 yield ReissueExternalNotesState::Failed(e.to_string());
1568 return;
1569 }
1570 }
1571 yield ReissueExternalNotesState::Done;
1572 }}
1573 ))
1574 }
1575
1576 #[deprecated(
1588 since = "0.5.0",
1589 note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
1590 )]
1591 pub async fn spend_notes<M: Serialize + Send>(
1592 &self,
1593 min_amount: Amount,
1594 try_cancel_after: Duration,
1595 include_invite: bool,
1596 extra_meta: M,
1597 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1598 self.spend_notes_with_selector(
1599 &SelectNotesWithAtleastAmount,
1600 min_amount,
1601 try_cancel_after,
1602 include_invite,
1603 extra_meta,
1604 )
1605 .await
1606 }
1607
1608 pub async fn spend_notes_with_selector<M: Serialize + Send>(
1624 &self,
1625 notes_selector: &impl NotesSelector,
1626 requested_amount: Amount,
1627 try_cancel_after: Duration,
1628 include_invite: bool,
1629 extra_meta: M,
1630 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1631 let federation_id_prefix = self.federation_id.to_prefix();
1632 let extra_meta = serde_json::to_value(extra_meta)
1633 .expect("MintClientModule::spend_notes extra_meta is serializable");
1634
1635 self.client_ctx
1636 .module_db()
1637 .autocommit(
1638 |dbtx, _| {
1639 let extra_meta = extra_meta.clone();
1640 Box::pin(async {
1641 let (operation_id, states, notes) = self
1642 .spend_notes_oob(
1643 dbtx,
1644 notes_selector,
1645 requested_amount,
1646 try_cancel_after,
1647 )
1648 .await?;
1649
1650 let oob_notes = if include_invite {
1651 OOBNotes::new_with_invite(
1652 notes,
1653 &self.client_ctx.get_invite_code().await,
1654 )
1655 } else {
1656 OOBNotes::new(federation_id_prefix, notes)
1657 };
1658
1659 self.client_ctx
1660 .add_state_machines_dbtx(
1661 dbtx,
1662 self.client_ctx.map_dyn(states).collect(),
1663 )
1664 .await?;
1665 self.client_ctx
1666 .add_operation_log_entry_dbtx(
1667 dbtx,
1668 operation_id,
1669 MintCommonInit::KIND.as_str(),
1670 MintOperationMeta {
1671 variant: MintOperationMetaVariant::SpendOOB {
1672 requested_amount,
1673 oob_notes: oob_notes.clone(),
1674 },
1675 amount: oob_notes.total_amount(),
1676 extra_meta,
1677 },
1678 )
1679 .await;
1680 self.client_ctx
1681 .log_event(
1682 dbtx,
1683 OOBNotesSpent {
1684 requested_amount,
1685 spent_amount: oob_notes.total_amount(),
1686 timeout: try_cancel_after,
1687 include_invite,
1688 },
1689 )
1690 .await;
1691
1692 Ok((operation_id, oob_notes))
1693 })
1694 },
1695 Some(100),
1696 )
1697 .await
1698 .map_err(|e| match e {
1699 AutocommitError::ClosureError { error, .. } => error,
1700 AutocommitError::CommitFailed { last_error, .. } => {
1701 anyhow!("Commit to DB failed: {last_error}")
1702 }
1703 })
1704 }
1705
1706 pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> {
1712 let federation_id_prefix = oob_notes.federation_id_prefix();
1713 let notes = oob_notes.notes().clone();
1714
1715 if federation_id_prefix != self.federation_id.to_prefix() {
1716 bail!("Federation ID does not match");
1717 }
1718
1719 let tbs_pks = &self.cfg.tbs_pks;
1720
1721 for (idx, (amt, snote)) in notes.iter_items().enumerate() {
1722 let key = tbs_pks
1723 .get(amt)
1724 .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
1725
1726 let note = snote.note();
1727 if !note.verify(*key) {
1728 bail!("Note {idx} has an invalid federation signature");
1729 }
1730
1731 let expected_nonce = Nonce(snote.spend_key.public_key());
1732 if note.nonce != expected_nonce {
1733 bail!("Note {idx} cannot be spent using the supplied spend key");
1734 }
1735 }
1736
1737 Ok(notes.total_amount())
1738 }
1739
1740 pub async fn check_note_spent(&self, oob_notes: &OOBNotes) -> anyhow::Result<bool> {
1746 use crate::api::MintFederationApi;
1747
1748 let api_client = self.client_ctx.module_api();
1749 let any_spent = try_join_all(oob_notes.notes().iter().flat_map(|(_, notes)| {
1750 notes
1751 .iter()
1752 .map(|note| api_client.check_note_spent(note.nonce()))
1753 }))
1754 .await?
1755 .into_iter()
1756 .any(|spent| spent);
1757
1758 Ok(any_spent)
1759 }
1760
1761 pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
1766 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1767 dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
1768 .await;
1769 if let Err(e) = dbtx.commit_tx_result().await {
1770 warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
1771 }
1772 }
1773
1774 pub async fn subscribe_spend_notes(
1777 &self,
1778 operation_id: OperationId,
1779 ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
1780 let operation = self.mint_operation(operation_id).await?;
1781 if !matches!(
1782 operation.meta::<MintOperationMeta>().variant,
1783 MintOperationMetaVariant::SpendOOB { .. }
1784 ) {
1785 bail!("Operation is not a out-of-band spend");
1786 }
1787
1788 let client_ctx = self.client_ctx.clone();
1789
1790 Ok(self
1791 .client_ctx
1792 .outcome_or_updates(operation, operation_id, move || {
1793 stream! {
1794 yield SpendOOBState::Created;
1795
1796 let self_ref = client_ctx.self_ref();
1797
1798 let refund = self_ref
1799 .await_spend_oob_refund(operation_id)
1800 .await;
1801
1802 if refund.user_triggered {
1803 yield SpendOOBState::UserCanceledProcessing;
1804 }
1805
1806 let mut success = true;
1807
1808 for txid in refund.transaction_ids {
1809 debug!(
1810 target: LOG_CLIENT_MODULE_MINT,
1811 %txid,
1812 operation_id=%operation_id.fmt_short(),
1813 "Waiting for oob refund txid"
1814 );
1815 if client_ctx
1816 .transaction_updates(operation_id)
1817 .await
1818 .await_tx_accepted(txid)
1819 .await.is_err() {
1820 success = false;
1821 }
1822 }
1823
1824 debug!(
1825 target: LOG_CLIENT_MODULE_MINT,
1826 operation_id=%operation_id.fmt_short(),
1827 %success,
1828 "Done waiting for all refund oob txids"
1829 );
1830
1831 match (refund.user_triggered, success) {
1832 (true, true) => {
1833 yield SpendOOBState::UserCanceledSuccess;
1834 },
1835 (true, false) => {
1836 yield SpendOOBState::UserCanceledFailure;
1837 },
1838 (false, true) => {
1839 yield SpendOOBState::Refunded;
1840 },
1841 (false, false) => {
1842 yield SpendOOBState::Success;
1843 }
1844 }
1845 }
1846 }))
1847 }
1848
1849 async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
1850 let operation = self.client_ctx.get_operation(operation_id).await?;
1851
1852 if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
1853 bail!("Operation is not a mint operation");
1854 }
1855
1856 Ok(operation)
1857 }
1858
1859 async fn delete_spendable_note(
1860 client_ctx: &ClientContext<MintClientModule>,
1861 dbtx: &mut DatabaseTransaction<'_>,
1862 amount: Amount,
1863 note: &SpendableNote,
1864 ) {
1865 client_ctx
1866 .log_event(
1867 dbtx,
1868 NoteSpent {
1869 nonce: note.nonce(),
1870 },
1871 )
1872 .await;
1873 dbtx.remove_entry(&NoteKey {
1874 amount,
1875 nonce: note.nonce(),
1876 })
1877 .await
1878 .expect("Must deleted existing spendable note");
1879 }
1880
1881 pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> {
1882 let db = self.client_ctx.module_db().clone();
1883
1884 Ok(db
1885 .autocommit(
1886 |dbtx, _| {
1887 Box::pin(async {
1888 Ok::<DerivableSecret, anyhow::Error>(
1889 self.new_note_secret(amount, dbtx).await,
1890 )
1891 })
1892 },
1893 None,
1894 )
1895 .await?)
1896 }
1897
1898 pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
1901 self.client_ctx
1902 .module_db()
1903 .begin_transaction_nc()
1904 .await
1905 .get_value(&ReusedNoteIndices)
1906 .await
1907 .unwrap_or_default()
1908 .into_iter()
1909 .map(|(amount, note_idx)| {
1910 let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
1911 let (request, blind_nonce) =
1912 NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
1913 (amount, request, blind_nonce)
1914 })
1915 .collect()
1916 }
1917}
1918
1919pub fn spendable_notes_to_operation_id(
1920 spendable_selected_notes: &TieredMulti<SpendableNote>,
1921) -> OperationId {
1922 OperationId(
1923 spendable_selected_notes
1924 .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
1925 .to_byte_array(),
1926 )
1927}
1928
1929#[derive(Debug, Serialize, Deserialize, Clone)]
1930pub struct SpendOOBRefund {
1931 pub user_triggered: bool,
1932 pub transaction_ids: Vec<TransactionId>,
1933}
1934
1935#[apply(async_trait_maybe_send!)]
1938pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
1939 async fn select_notes(
1942 &self,
1943 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
1945 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
1946 requested_amount: Amount,
1947 fee_consensus: FeeConsensus,
1948 ) -> anyhow::Result<TieredMulti<Note>>;
1949}
1950
1951pub struct SelectNotesWithAtleastAmount;
1957
1958#[apply(async_trait_maybe_send!)]
1959impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
1960 async fn select_notes(
1961 &self,
1962 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
1963 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
1964 requested_amount: Amount,
1965 fee_consensus: FeeConsensus,
1966 ) -> anyhow::Result<TieredMulti<Note>> {
1967 Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
1968 }
1969}
1970
1971pub struct SelectNotesWithExactAmount;
1975
1976#[apply(async_trait_maybe_send!)]
1977impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
1978 async fn select_notes(
1979 &self,
1980 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
1981 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
1982 requested_amount: Amount,
1983 fee_consensus: FeeConsensus,
1984 ) -> anyhow::Result<TieredMulti<Note>> {
1985 let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
1986
1987 if notes.total_amount() != requested_amount {
1988 bail!(
1989 "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}",
1990 requested_amount,
1991 notes.total_amount()
1992 );
1993 }
1994
1995 Ok(notes)
1996 }
1997}
1998
1999async fn select_notes_from_stream<Note>(
2005 stream: impl futures::Stream<Item = (Amount, Note)>,
2006 requested_amount: Amount,
2007 fee_consensus: FeeConsensus,
2008) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
2009 if requested_amount == Amount::ZERO {
2010 return Ok(TieredMulti::default());
2011 }
2012 let mut stream = Box::pin(stream);
2013 let mut selected = vec![];
2014 let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
2019 let mut pending_amount = requested_amount;
2020 let mut previous_amount: Option<Amount> = None; loop {
2022 if let Some((note_amount, note)) = stream.next().await {
2023 assert!(
2024 previous_amount.is_none_or(|previous| previous >= note_amount),
2025 "notes are not sorted in descending order"
2026 );
2027 previous_amount = Some(note_amount);
2028
2029 if note_amount <= fee_consensus.fee(note_amount) {
2030 continue;
2031 }
2032
2033 match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2034 Ordering::Less => {
2035 pending_amount += fee_consensus.fee(note_amount);
2037 pending_amount -= note_amount;
2038 selected.push((note_amount, note));
2039 }
2040 Ordering::Greater => {
2041 last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2045 }
2046 Ordering::Equal => {
2047 selected.push((note_amount, note));
2049
2050 let notes: TieredMulti<Note> = selected.into_iter().collect();
2051
2052 assert!(
2053 notes.total_amount().msats
2054 >= requested_amount.msats
2055 + notes
2056 .iter()
2057 .map(|note| fee_consensus.fee(note.0))
2058 .sum::<Amount>()
2059 .msats
2060 );
2061
2062 return Ok(notes);
2063 }
2064 }
2065 } else {
2066 assert!(pending_amount > Amount::ZERO);
2067 if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2068 selected.truncate(checkpoint);
2071 selected.push((big_note_amount, big_note));
2073
2074 let notes: TieredMulti<Note> = selected.into_iter().collect();
2075
2076 assert!(
2077 notes.total_amount().msats
2078 >= requested_amount.msats
2079 + notes
2080 .iter()
2081 .map(|note| fee_consensus.fee(note.0))
2082 .sum::<Amount>()
2083 .msats
2084 );
2085
2086 return Ok(notes);
2088 }
2089
2090 let total_amount = requested_amount.saturating_sub(pending_amount);
2091 return Err(InsufficientBalanceError {
2093 requested_amount,
2094 total_amount,
2095 });
2096 }
2097 }
2098}
2099
2100#[derive(Debug, Clone, Error)]
2101pub struct InsufficientBalanceError {
2102 pub requested_amount: Amount,
2103 pub total_amount: Amount,
2104}
2105
2106impl std::fmt::Display for InsufficientBalanceError {
2107 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2108 write!(
2109 f,
2110 "Insufficient balance: requested {} but only {} available",
2111 self.requested_amount, self.total_amount
2112 )
2113 }
2114}
2115
2116#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2118enum MintRestoreStates {
2119 #[encodable_default]
2120 Default { variant: u64, bytes: Vec<u8> },
2121}
2122
2123#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2125pub struct MintRestoreStateMachine {
2126 operation_id: OperationId,
2127 state: MintRestoreStates,
2128}
2129
2130#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2131pub enum MintClientStateMachines {
2132 Output(MintOutputStateMachine),
2133 Input(MintInputStateMachine),
2134 OOB(MintOOBStateMachine),
2135 Restore(MintRestoreStateMachine),
2137}
2138
2139impl IntoDynInstance for MintClientStateMachines {
2140 type DynType = DynState;
2141
2142 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2143 DynState::from_typed(instance_id, self)
2144 }
2145}
2146
2147impl State for MintClientStateMachines {
2148 type ModuleContext = MintClientContext;
2149
2150 fn transitions(
2151 &self,
2152 context: &Self::ModuleContext,
2153 global_context: &DynGlobalClientContext,
2154 ) -> Vec<StateTransition<Self>> {
2155 match self {
2156 MintClientStateMachines::Output(issuance_state) => {
2157 sm_enum_variant_translation!(
2158 issuance_state.transitions(context, global_context),
2159 MintClientStateMachines::Output
2160 )
2161 }
2162 MintClientStateMachines::Input(redemption_state) => {
2163 sm_enum_variant_translation!(
2164 redemption_state.transitions(context, global_context),
2165 MintClientStateMachines::Input
2166 )
2167 }
2168 MintClientStateMachines::OOB(oob_state) => {
2169 sm_enum_variant_translation!(
2170 oob_state.transitions(context, global_context),
2171 MintClientStateMachines::OOB
2172 )
2173 }
2174 MintClientStateMachines::Restore(_) => {
2175 sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
2176 }
2177 }
2178 }
2179
2180 fn operation_id(&self) -> OperationId {
2181 match self {
2182 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
2183 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
2184 MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
2185 MintClientStateMachines::Restore(r) => r.operation_id,
2186 }
2187 }
2188}
2189
2190#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
2193pub struct SpendableNote {
2194 pub signature: tbs::Signature,
2195 pub spend_key: Keypair,
2196}
2197
2198impl fmt::Debug for SpendableNote {
2199 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2200 f.debug_struct("SpendableNote")
2201 .field("nonce", &self.nonce())
2202 .field("signature", &self.signature)
2203 .field("spend_key", &self.spend_key)
2204 .finish()
2205 }
2206}
2207impl fmt::Display for SpendableNote {
2208 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2209 self.nonce().fmt(f)
2210 }
2211}
2212
2213impl SpendableNote {
2214 pub fn nonce(&self) -> Nonce {
2215 Nonce(self.spend_key.public_key())
2216 }
2217
2218 fn note(&self) -> Note {
2219 Note {
2220 nonce: self.nonce(),
2221 signature: self.signature,
2222 }
2223 }
2224
2225 pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
2226 SpendableNoteUndecoded {
2227 signature: self
2228 .signature
2229 .consensus_encode_to_vec()
2230 .try_into()
2231 .expect("Encoded size always correct"),
2232 spend_key: self.spend_key,
2233 }
2234 }
2235}
2236
2237#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
2249pub struct SpendableNoteUndecoded {
2250 #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
2253 pub signature: [u8; 48],
2254 pub spend_key: Keypair,
2255}
2256
2257impl fmt::Display for SpendableNoteUndecoded {
2258 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2259 self.nonce().fmt(f)
2260 }
2261}
2262
2263impl fmt::Debug for SpendableNoteUndecoded {
2264 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2265 f.debug_struct("SpendableNote")
2266 .field("nonce", &self.nonce())
2267 .field("signature", &"[raw]")
2268 .field("spend_key", &self.spend_key)
2269 .finish()
2270 }
2271}
2272
2273impl SpendableNoteUndecoded {
2274 fn nonce(&self) -> Nonce {
2275 Nonce(self.spend_key.public_key())
2276 }
2277
2278 pub fn decode(self) -> anyhow::Result<SpendableNote> {
2279 Ok(SpendableNote {
2280 signature: Decodable::consensus_decode_partial_from_finite_reader(
2281 &mut self.signature.as_slice(),
2282 &ModuleRegistry::default(),
2283 )?,
2284 spend_key: self.spend_key,
2285 })
2286 }
2287}
2288
2289#[derive(
2295 Copy,
2296 Clone,
2297 Debug,
2298 Serialize,
2299 Deserialize,
2300 PartialEq,
2301 Eq,
2302 Encodable,
2303 Decodable,
2304 Default,
2305 PartialOrd,
2306 Ord,
2307)]
2308pub struct NoteIndex(u64);
2309
2310impl NoteIndex {
2311 pub fn next(self) -> Self {
2312 Self(self.0 + 1)
2313 }
2314
2315 fn prev(self) -> Option<Self> {
2316 self.0.checked_sub(0).map(Self)
2317 }
2318
2319 pub fn as_u64(self) -> u64 {
2320 self.0
2321 }
2322
2323 #[allow(unused)]
2327 pub fn from_u64(v: u64) -> Self {
2328 Self(v)
2329 }
2330
2331 pub fn advance(&mut self) {
2332 *self = self.next();
2333 }
2334}
2335
2336impl std::fmt::Display for NoteIndex {
2337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2338 self.0.fmt(f)
2339 }
2340}
2341
2342struct OOBSpendTag;
2343
2344impl sha256t::Tag for OOBSpendTag {
2345 fn engine() -> sha256::HashEngine {
2346 let mut engine = sha256::HashEngine::default();
2347 engine.input(b"oob-spend");
2348 engine
2349 }
2350}
2351
2352struct OOBReissueTag;
2353
2354impl sha256t::Tag for OOBReissueTag {
2355 fn engine() -> sha256::HashEngine {
2356 let mut engine = sha256::HashEngine::default();
2357 engine.input(b"oob-reissue");
2358 engine
2359 }
2360}
2361
2362pub fn represent_amount<K>(
2368 amount: Amount,
2369 current_denominations: &TieredCounts,
2370 tiers: &Tiered<K>,
2371 denomination_sets: u16,
2372 fee_consensus: &FeeConsensus,
2373) -> TieredCounts {
2374 let mut remaining_amount = amount;
2375 let mut denominations = TieredCounts::default();
2376
2377 for tier in tiers.tiers() {
2379 let notes = current_denominations.get(*tier);
2380 let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
2381 let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
2382
2383 let add_notes = min(possible_notes, missing_notes);
2384 denominations.inc(*tier, add_notes as usize);
2385 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
2386 }
2387
2388 for tier in tiers.tiers().rev() {
2390 let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
2391 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
2392 denominations.inc(*tier, res as usize);
2393 }
2394
2395 let represented: u64 = denominations
2396 .iter()
2397 .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
2398 .sum();
2399
2400 assert!(represented <= amount.msats);
2401 assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
2402
2403 denominations
2404}
2405
2406pub(crate) fn create_bundle_for_inputs(
2407 inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
2408 operation_id: OperationId,
2409) -> ClientInputBundle<MintInput, MintClientStateMachines> {
2410 let mut inputs = Vec::new();
2411 let mut input_states = Vec::new();
2412
2413 for (input, spendable_note) in inputs_and_notes {
2414 input_states.push((input.amount, spendable_note));
2415 inputs.push(input);
2416 }
2417
2418 let input_sm = Arc::new(move |out_point_range: OutPointRange| {
2419 debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
2420
2421 vec![MintClientStateMachines::Input(MintInputStateMachine {
2422 common: MintInputCommon {
2423 operation_id,
2424 out_point_range,
2425 },
2426 state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
2427 notes: input_states.clone(),
2428 }),
2429 })]
2430 });
2431
2432 ClientInputBundle::new(
2433 inputs,
2434 vec![ClientInputSM {
2435 state_machines: input_sm,
2436 }],
2437 )
2438}
2439
2440#[cfg(test)]
2441mod tests {
2442 use std::fmt::Display;
2443 use std::str::FromStr;
2444
2445 use bitcoin_hashes::Hash;
2446 use fedimint_core::base32::FEDIMINT_PREFIX;
2447 use fedimint_core::config::FederationId;
2448 use fedimint_core::encoding::Decodable;
2449 use fedimint_core::invite_code::InviteCode;
2450 use fedimint_core::module::registry::ModuleRegistry;
2451 use fedimint_core::{
2452 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
2453 };
2454 use fedimint_mint_common::config::FeeConsensus;
2455 use itertools::Itertools;
2456 use serde_json::json;
2457
2458 use crate::{
2459 MintOperationMetaVariant, OOBNotes, OOBNotesPart, SpendableNote, SpendableNoteUndecoded,
2460 represent_amount, select_notes_from_stream,
2461 };
2462
2463 #[test]
2464 fn represent_amount_targets_denomination_sets() {
2465 fn tiers(tiers: Vec<u64>) -> Tiered<()> {
2466 tiers
2467 .into_iter()
2468 .map(|tier| (Amount::from_sats(tier), ()))
2469 .collect()
2470 }
2471
2472 fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
2473 TieredCounts::from_iter(denominations)
2474 }
2475
2476 let starting = notes(vec![
2477 (Amount::from_sats(1), 1),
2478 (Amount::from_sats(2), 3),
2479 (Amount::from_sats(3), 2),
2480 ])
2481 .summary();
2482 let tiers = tiers(vec![1, 2, 3, 4]);
2483
2484 assert_eq!(
2486 represent_amount(
2487 Amount::from_sats(6),
2488 &starting,
2489 &tiers,
2490 3,
2491 &FeeConsensus::zero()
2492 ),
2493 denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
2494 );
2495
2496 assert_eq!(
2498 represent_amount(
2499 Amount::from_sats(6),
2500 &starting,
2501 &tiers,
2502 2,
2503 &FeeConsensus::zero()
2504 ),
2505 denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
2506 );
2507 }
2508
2509 #[test_log::test(tokio::test)]
2510 async fn select_notes_avg_test() {
2511 let max_amount = Amount::from_sats(1_000_000);
2512 let tiers = Tiered::gen_denominations(2, max_amount);
2513 let tiered = represent_amount::<()>(
2514 max_amount,
2515 &TieredCounts::default(),
2516 &tiers,
2517 3,
2518 &FeeConsensus::zero(),
2519 );
2520
2521 let mut total_notes = 0;
2522 for multiplier in 1..100 {
2523 let stream = reverse_sorted_note_stream(tiered.iter().collect());
2524 let select = select_notes_from_stream(
2525 stream,
2526 Amount::from_sats(multiplier * 1000),
2527 FeeConsensus::zero(),
2528 )
2529 .await;
2530 total_notes += select.unwrap().into_iter_items().count();
2531 }
2532 assert_eq!(total_notes / 100, 10);
2533 }
2534
2535 #[test_log::test(tokio::test)]
2536 async fn select_notes_returns_exact_amount_with_minimum_notes() {
2537 let f = || {
2538 reverse_sorted_note_stream(vec![
2539 (Amount::from_sats(1), 10),
2540 (Amount::from_sats(5), 10),
2541 (Amount::from_sats(20), 10),
2542 ])
2543 };
2544 assert_eq!(
2545 select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
2546 .await
2547 .unwrap(),
2548 notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
2549 );
2550 assert_eq!(
2551 select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
2552 .await
2553 .unwrap(),
2554 notes(vec![(Amount::from_sats(20), 1)])
2555 );
2556 }
2557
2558 #[test_log::test(tokio::test)]
2559 async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
2560 let stream = reverse_sorted_note_stream(vec![
2561 (Amount::from_sats(1), 1),
2562 (Amount::from_sats(5), 5),
2563 (Amount::from_sats(20), 5),
2564 ]);
2565 assert_eq!(
2566 select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
2567 .await
2568 .unwrap(),
2569 notes(vec![(Amount::from_sats(5), 2)])
2570 );
2571 }
2572
2573 #[test_log::test(tokio::test)]
2574 async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
2575 let stream = reverse_sorted_note_stream(vec![
2576 (Amount::from_sats(1), 3),
2577 (Amount::from_sats(5), 3),
2578 (Amount::from_sats(20), 2),
2579 ]);
2580 assert_eq!(
2581 select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
2582 .await
2583 .unwrap(),
2584 notes(vec![(Amount::from_sats(20), 2)])
2585 );
2586 }
2587
2588 #[test_log::test(tokio::test)]
2589 async fn select_notes_returns_error_if_amount_is_too_large() {
2590 let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
2591 let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
2592 .await
2593 .unwrap_err();
2594 assert_eq!(error.total_amount, Amount::from_sats(10));
2595 }
2596
2597 fn reverse_sorted_note_stream(
2598 notes: Vec<(Amount, usize)>,
2599 ) -> impl futures::Stream<Item = (Amount, String)> {
2600 futures::stream::iter(
2601 notes
2602 .into_iter()
2603 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2605 .sorted()
2606 .rev(),
2607 )
2608 }
2609
2610 fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
2611 notes
2612 .into_iter()
2613 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2614 .collect()
2615 }
2616
2617 #[test]
2618 fn decoding_empty_oob_notes_fails() {
2619 let empty_oob_notes =
2620 OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
2621 let oob_notes_string = empty_oob_notes.to_string();
2622
2623 let res = oob_notes_string.parse::<OOBNotes>();
2624
2625 assert!(res.is_err(), "An empty OOB notes string should not parse");
2626 }
2627
2628 fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
2629 where
2630 T: FromStr + Display + crate::Encodable + crate::Decodable,
2631 <T as FromStr>::Err: std::fmt::Debug,
2632 F: Fn(T),
2633 {
2634 let data_parsed = data.to_string().parse().expect("Deserialization failed");
2635
2636 assertions(data_parsed);
2637
2638 let data_parsed = crate::base32::encode_prefixed(FEDIMINT_PREFIX, &data)
2639 .parse()
2640 .expect("Deserialization failed");
2641
2642 assertions(data_parsed);
2643
2644 assertions(data);
2645 }
2646
2647 #[test]
2648 fn notes_encode_decode() {
2649 let federation_id_1 =
2650 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
2651 let federation_id_prefix_1 = federation_id_1.to_prefix();
2652 let federation_id_2 =
2653 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
2654 let federation_id_prefix_2 = federation_id_2.to_prefix();
2655
2656 let notes = vec![(
2657 Amount::from_sats(1),
2658 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
2659 )]
2660 .into_iter()
2661 .collect::<TieredMulti<_>>();
2662
2663 let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
2665 test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
2666 assert_eq!(oob_notes.notes(), ¬es);
2667 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2668 assert_eq!(oob_notes.federation_invite(), None);
2669 });
2670
2671 let invite = InviteCode::new(
2673 "wss://foo.bar".parse().unwrap(),
2674 PeerId::from(0),
2675 federation_id_1,
2676 None,
2677 );
2678 let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
2679 test_roundtrip_serialize_str(notes_invite, |oob_notes| {
2680 assert_eq!(oob_notes.notes(), ¬es);
2681 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2682 assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
2683 });
2684
2685 let notes_no_prefix = OOBNotes(vec![
2688 OOBNotesPart::Notes(notes.clone()),
2689 OOBNotesPart::Invite {
2690 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2691 federation_id: federation_id_1,
2692 },
2693 ]);
2694 test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
2695 assert_eq!(oob_notes.notes(), ¬es);
2696 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2697 });
2698
2699 let notes_inconsistent = OOBNotes(vec![
2701 OOBNotesPart::Notes(notes),
2702 OOBNotesPart::Invite {
2703 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2704 federation_id: federation_id_1,
2705 },
2706 OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
2707 ]);
2708 let notes_inconsistent_str = notes_inconsistent.to_string();
2709 assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
2710 }
2711
2712 #[test]
2713 fn spendable_note_undecoded_sanity() {
2714 #[allow(clippy::single_element_loop)]
2716 for note_hex in [
2717 "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
2718 ] {
2719 let note =
2720 SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
2721 let note_undecoded =
2722 SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
2723 .unwrap()
2724 .decode()
2725 .unwrap();
2726 assert_eq!(note, note_undecoded,);
2727 assert_eq!(
2728 serde_json::to_string(¬e).unwrap(),
2729 serde_json::to_string(¬e_undecoded).unwrap(),
2730 );
2731 }
2732 }
2733
2734 #[test]
2735 fn reissuance_meta_compatibility_02_03() {
2736 let dummy_outpoint = OutPoint {
2737 txid: TransactionId::all_zeros(),
2738 out_idx: 0,
2739 };
2740
2741 let old_meta_json = json!({
2742 "reissuance": {
2743 "out_point": dummy_outpoint
2744 }
2745 });
2746
2747 let old_meta: MintOperationMetaVariant =
2748 serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
2749 assert_eq!(
2750 old_meta,
2751 MintOperationMetaVariant::Reissuance {
2752 legacy_out_point: Some(dummy_outpoint),
2753 txid: None,
2754 out_point_indices: vec![],
2755 }
2756 );
2757
2758 let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
2759 legacy_out_point: None,
2760 txid: Some(dummy_outpoint.txid),
2761 out_point_indices: vec![0],
2762 })
2763 .expect("serializing always works");
2764 assert_eq!(
2765 new_meta_json,
2766 json!({
2767 "reissuance": {
2768 "txid": dummy_outpoint.txid,
2769 "out_point_indices": [dummy_outpoint.out_idx],
2770 }
2771 })
2772 );
2773 }
2774}