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