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::{ClientModuleMigrationFn, 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, ClientModuleMigrationFn> {
558 let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = 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(_) | MintOOBStates::CreatedMulti(_),
795 ..
796 }) => Some(()),
797 MintClientStateMachines::Output(MintOutputStateMachine {
800 state:
801 MintOutputStates::Created(_)
802 | MintOutputStates::CreatedMulti(_)
803 | MintOutputStates::Failed(_)
804 | MintOutputStates::Aborted(_),
805 ..
806 })
807 | MintClientStateMachines::Input(MintInputStateMachine {
808 state:
809 MintInputStates::Error(_)
810 | MintInputStates::Success(_)
811 | MintInputStates::Refund(_)
812 | MintInputStates::RefundedBundle(_)
813 | MintInputStates::RefundedPerNote(_)
814 | MintInputStates::RefundSuccess(_),
815 ..
816 })
817 | MintClientStateMachines::OOB(MintOOBStateMachine {
818 state:
819 MintOOBStates::TimeoutRefund(_)
820 | MintOOBStates::UserRefund(_)
821 | MintOOBStates::UserRefundMulti(_),
822 ..
823 })
824 | MintClientStateMachines::Restore(_) => None,
825 }
826 }),
827 )
828 }
829
830 async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
831 let balance = ClientModule::get_balance(self, dbtx).await;
832 if Amount::from_sats(0) < balance {
833 bail!("Outstanding balance: {balance}");
834 }
835
836 if !self.client_ctx.get_own_active_states().await.is_empty() {
837 bail!("Pending operations")
838 }
839 Ok(())
840 }
841 async fn handle_rpc(
842 &self,
843 method: String,
844 request: serde_json::Value,
845 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
846 Box::pin(try_stream! {
847 match method.as_str() {
848 "reissue_external_notes" => {
849 let req: ReissueExternalNotesRequest = serde_json::from_value(request)?;
850 let result = self.reissue_external_notes(req.oob_notes, req.extra_meta).await?;
851 yield serde_json::to_value(result)?;
852 }
853 "subscribe_reissue_external_notes" => {
854 let req: SubscribeReissueExternalNotesRequest = serde_json::from_value(request)?;
855 let stream = self.subscribe_reissue_external_notes(req.operation_id).await?;
856 for await state in stream.into_stream() {
857 yield serde_json::to_value(state)?;
858 }
859 }
860 "spend_notes" => {
861 let req: SpendNotesRequest = serde_json::from_value(request)?;
862 let result = self.spend_notes_with_selector(
863 &SelectNotesWithExactAmount,
864 req.amount,
865 req.try_cancel_after,
866 req.include_invite,
867 req.extra_meta
868 ).await?;
869 yield serde_json::to_value(result)?;
870 }
871 "spend_notes_expert" => {
872 let req: SpendNotesExpertRequest = serde_json::from_value(request)?;
873 let result = self.spend_notes_with_selector(
874 &SelectNotesWithAtleastAmount,
875 req.min_amount,
876 req.try_cancel_after,
877 req.include_invite,
878 req.extra_meta
879 ).await?;
880 yield serde_json::to_value(result)?;
881 }
882 "validate_notes" => {
883 let req: ValidateNotesRequest = serde_json::from_value(request)?;
884 let result = self.validate_notes(&req.oob_notes)?;
885 yield serde_json::to_value(result)?;
886 }
887 "try_cancel_spend_notes" => {
888 let req: TryCancelSpendNotesRequest = serde_json::from_value(request)?;
889 let result = self.try_cancel_spend_notes(req.operation_id).await;
890 yield serde_json::to_value(result)?;
891 }
892 "subscribe_spend_notes" => {
893 let req: SubscribeSpendNotesRequest = serde_json::from_value(request)?;
894 let stream = self.subscribe_spend_notes(req.operation_id).await?;
895 for await state in stream.into_stream() {
896 yield serde_json::to_value(state)?;
897 }
898 }
899 "await_spend_oob_refund" => {
900 let req: AwaitSpendOobRefundRequest = serde_json::from_value(request)?;
901 let value = self.await_spend_oob_refund(req.operation_id).await;
902 yield serde_json::to_value(value)?;
903 }
904 "note_counts_by_denomination" => {
905 let mut dbtx = self.client_ctx.module_db().begin_transaction_nc().await;
906 let note_counts = self.get_note_counts_by_denomination(&mut dbtx).await;
907 yield serde_json::to_value(note_counts)?;
908 }
909 _ => {
910 Err(anyhow::format_err!("Unknown method: {}", method))?;
911 unreachable!()
912 },
913 }
914 })
915 }
916}
917
918#[derive(Deserialize)]
919struct ReissueExternalNotesRequest {
920 oob_notes: OOBNotes,
921 extra_meta: serde_json::Value,
922}
923
924#[derive(Deserialize)]
925struct SubscribeReissueExternalNotesRequest {
926 operation_id: OperationId,
927}
928
929#[derive(Deserialize)]
932struct SpendNotesExpertRequest {
933 min_amount: Amount,
934 try_cancel_after: Duration,
935 include_invite: bool,
936 extra_meta: serde_json::Value,
937}
938
939#[derive(Deserialize)]
940struct SpendNotesRequest {
941 amount: Amount,
942 try_cancel_after: Duration,
943 include_invite: bool,
944 extra_meta: serde_json::Value,
945}
946
947#[derive(Deserialize)]
948struct ValidateNotesRequest {
949 oob_notes: OOBNotes,
950}
951
952#[derive(Deserialize)]
953struct TryCancelSpendNotesRequest {
954 operation_id: OperationId,
955}
956
957#[derive(Deserialize)]
958struct SubscribeSpendNotesRequest {
959 operation_id: OperationId,
960}
961
962#[derive(Deserialize)]
963struct AwaitSpendOobRefundRequest {
964 operation_id: OperationId,
965}
966
967#[derive(thiserror::Error, Debug, Clone)]
968pub enum ReissueExternalNotesError {
969 #[error("Federation ID does not match")]
970 WrongFederationId,
971 #[error("We already reissued these notes")]
972 AlreadyReissued,
973}
974
975impl MintClientModule {
976 async fn create_sufficient_input(
977 &self,
978 dbtx: &mut DatabaseTransaction<'_>,
979 min_amount: Amount,
980 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
981 if min_amount == Amount::ZERO {
982 return Ok(vec![]);
983 }
984
985 let selected_notes = Self::select_notes(
986 dbtx,
987 &SelectNotesWithAtleastAmount,
988 min_amount,
989 self.cfg.fee_consensus.clone(),
990 )
991 .await?;
992
993 for (amount, note) in selected_notes.iter_items() {
994 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as sufficient input to fund a tx");
995 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
996 }
997
998 let inputs = self.create_input_from_notes(selected_notes)?;
999
1000 assert!(!inputs.is_empty());
1001
1002 Ok(inputs)
1003 }
1004
1005 #[deprecated(
1007 since = "0.5.0",
1008 note = "Use `get_note_counts_by_denomination` instead"
1009 )]
1010 pub async fn get_notes_tier_counts(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1011 self.get_note_counts_by_denomination(dbtx).await
1012 }
1013
1014 pub async fn get_available_notes_by_tier_counts(
1018 &self,
1019 dbtx: &mut DatabaseTransaction<'_>,
1020 counts: TieredCounts,
1021 ) -> (TieredMulti<SpendableNoteUndecoded>, TieredCounts) {
1022 dbtx.find_by_prefix(&NoteKeyPrefix)
1023 .await
1024 .fold(
1025 (TieredMulti::<SpendableNoteUndecoded>::default(), counts),
1026 |(mut notes, mut counts), (key, note)| async move {
1027 let amount = key.amount;
1028 if 0 < counts.get(amount) {
1029 counts.dec(amount);
1030 notes.push(amount, note);
1031 }
1032
1033 (notes, counts)
1034 },
1035 )
1036 .await
1037 }
1038
1039 pub async fn create_output(
1044 &self,
1045 dbtx: &mut DatabaseTransaction<'_>,
1046 operation_id: OperationId,
1047 notes_per_denomination: u16,
1048 exact_amount: Amount,
1049 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> {
1050 if exact_amount == Amount::ZERO {
1051 return ClientOutputBundle::new(vec![], vec![]);
1052 }
1053
1054 let denominations = represent_amount(
1055 exact_amount,
1056 &self.get_note_counts_by_denomination(dbtx).await,
1057 &self.cfg.tbs_pks,
1058 notes_per_denomination,
1059 &self.cfg.fee_consensus,
1060 );
1061
1062 let mut outputs = Vec::new();
1063 let mut issuance_requests = Vec::new();
1064
1065 for (amount, num) in denominations.iter() {
1066 for _ in 0..num {
1067 let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await;
1068
1069 debug!(
1070 %amount,
1071 "Generated issuance request"
1072 );
1073
1074 outputs.push(ClientOutput {
1075 output: MintOutput::new_v0(amount, blind_nonce),
1076 amount,
1077 });
1078
1079 issuance_requests.push((amount, issuance_request));
1080 }
1081 }
1082
1083 let state_generator = Arc::new(move |out_point_range: OutPointRange| {
1084 assert_eq!(out_point_range.count(), issuance_requests.len());
1085 vec![MintClientStateMachines::Output(MintOutputStateMachine {
1086 common: MintOutputCommon {
1087 operation_id,
1088 out_point_range,
1089 },
1090 state: MintOutputStates::CreatedMulti(MintOutputStatesCreatedMulti {
1091 issuance_requests: out_point_range
1092 .into_iter()
1093 .map(|out_point| out_point.out_idx)
1094 .zip(issuance_requests.clone())
1095 .collect(),
1096 }),
1097 })]
1098 });
1099
1100 ClientOutputBundle::new(
1101 outputs,
1102 vec![ClientOutputSM {
1103 state_machines: state_generator,
1104 }],
1105 )
1106 }
1107
1108 pub async fn get_note_counts_by_denomination(
1110 &self,
1111 dbtx: &mut DatabaseTransaction<'_>,
1112 ) -> TieredCounts {
1113 dbtx.find_by_prefix(&NoteKeyPrefix)
1114 .await
1115 .fold(
1116 TieredCounts::default(),
1117 |mut acc, (key, _note)| async move {
1118 acc.inc(key.amount, 1);
1119 acc
1120 },
1121 )
1122 .await
1123 }
1124
1125 #[deprecated(
1127 since = "0.5.0",
1128 note = "Use `get_note_counts_by_denomination` instead"
1129 )]
1130 pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts {
1131 self.get_note_counts_by_denomination(dbtx).await
1132 }
1133
1134 pub async fn await_output_finalized(
1138 &self,
1139 operation_id: OperationId,
1140 out_point: OutPoint,
1141 ) -> anyhow::Result<()> {
1142 let stream = self
1143 .notifier
1144 .subscribe(operation_id)
1145 .await
1146 .filter_map(|state| async {
1147 let MintClientStateMachines::Output(state) = state else {
1148 return None;
1149 };
1150
1151 if state.common.txid() != out_point.txid
1152 || !state
1153 .common
1154 .out_point_range
1155 .out_idx_iter()
1156 .contains(&out_point.out_idx)
1157 {
1158 return None;
1159 }
1160
1161 match state.state {
1162 MintOutputStates::Succeeded(_) => Some(Ok(())),
1163 MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))),
1164 MintOutputStates::Failed(failed) => Some(Err(anyhow!(
1165 "Failed to finalize transaction: {}",
1166 failed.error
1167 ))),
1168 MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None,
1169 }
1170 });
1171 pin_mut!(stream);
1172
1173 stream.next_or_pending().await
1174 }
1175
1176 pub async fn consolidate_notes(
1183 &self,
1184 dbtx: &mut DatabaseTransaction<'_>,
1185 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1186 const MAX_NOTES_PER_TIER_TRIGGER: usize = 8;
1189 const MIN_NOTES_PER_TIER: usize = 4;
1191 const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20;
1194 #[allow(clippy::assertions_on_constants)]
1196 {
1197 assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER);
1198 }
1199
1200 let counts = self.get_note_counts_by_denomination(dbtx).await;
1201
1202 let should_consolidate = counts
1203 .iter()
1204 .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count);
1205
1206 if !should_consolidate {
1207 return Ok(vec![]);
1208 }
1209
1210 let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX;
1211
1212 let excessive_counts: TieredCounts = counts
1213 .iter()
1214 .map(|(amount, count)| {
1215 let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count);
1216
1217 max_count -= take;
1218 (amount, take)
1219 })
1220 .collect();
1221
1222 let (selected_notes, unavailable) = self
1223 .get_available_notes_by_tier_counts(dbtx, excessive_counts)
1224 .await;
1225
1226 debug_assert!(
1227 unavailable.is_empty(),
1228 "Can't have unavailable notes on a subset of all notes: {unavailable:?}"
1229 );
1230
1231 if !selected_notes.is_empty() {
1232 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");
1233 }
1234
1235 let mut selected_notes_decoded = vec![];
1236 for (amount, note) in selected_notes.iter_items() {
1237 let spendable_note_decoded = note.decode()?;
1238 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note");
1239 Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded)
1240 .await;
1241 selected_notes_decoded.push((amount, spendable_note_decoded));
1242 }
1243
1244 self.create_input_from_notes(selected_notes_decoded.into_iter().collect())
1245 }
1246
1247 #[allow(clippy::type_complexity)]
1249 pub fn create_input_from_notes(
1250 &self,
1251 notes: TieredMulti<SpendableNote>,
1252 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> {
1253 let mut inputs_and_notes = Vec::new();
1254
1255 for (amount, spendable_note) in notes.into_iter_items() {
1256 let key = self
1257 .cfg
1258 .tbs_pks
1259 .get(amount)
1260 .ok_or(anyhow!("Invalid amount tier: {amount}"))?;
1261
1262 let note = spendable_note.note();
1263
1264 if !note.verify(*key) {
1265 bail!("Invalid note");
1266 }
1267
1268 inputs_and_notes.push((
1269 ClientInput {
1270 input: MintInput::new_v0(amount, note),
1271 keys: vec![spendable_note.spend_key],
1272 amount,
1273 },
1274 spendable_note,
1275 ));
1276 }
1277
1278 Ok(inputs_and_notes)
1279 }
1280
1281 async fn spend_notes_oob(
1282 &self,
1283 dbtx: &mut DatabaseTransaction<'_>,
1284 notes_selector: &impl NotesSelector,
1285 amount: Amount,
1286 try_cancel_after: Duration,
1287 ) -> anyhow::Result<(
1288 OperationId,
1289 Vec<MintClientStateMachines>,
1290 TieredMulti<SpendableNote>,
1291 )> {
1292 ensure!(
1293 amount > Amount::ZERO,
1294 "zero-amount out-of-band spends are not supported"
1295 );
1296
1297 let selected_notes =
1298 Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?;
1299
1300 let operation_id = spendable_notes_to_operation_id(&selected_notes);
1301
1302 for (amount, note) in selected_notes.iter_items() {
1303 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob");
1304 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await;
1305 }
1306
1307 let state_machines = vec![MintClientStateMachines::OOB(MintOOBStateMachine {
1308 operation_id,
1309 state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti {
1310 spendable_notes: selected_notes.clone().into_iter_items().collect(),
1311 timeout: fedimint_core::time::now() + try_cancel_after,
1312 }),
1313 })];
1314
1315 Ok((operation_id, state_machines, selected_notes))
1316 }
1317
1318 pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund {
1319 Box::pin(
1320 self.notifier
1321 .subscribe(operation_id)
1322 .await
1323 .filter_map(|state| async {
1324 let MintClientStateMachines::OOB(state) = state else {
1325 return None;
1326 };
1327
1328 match state.state {
1329 MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund {
1330 user_triggered: false,
1331 transaction_ids: vec![refund.refund_txid],
1332 }),
1333 MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund {
1334 user_triggered: true,
1335 transaction_ids: vec![refund.refund_txid],
1336 }),
1337 MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund {
1338 user_triggered: true,
1339 transaction_ids: vec![refund.refund_txid],
1340 }),
1341 MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None,
1342 }
1343 }),
1344 )
1345 .next_or_pending()
1346 .await
1347 }
1348
1349 async fn select_notes(
1351 dbtx: &mut DatabaseTransaction<'_>,
1352 notes_selector: &impl NotesSelector,
1353 requested_amount: Amount,
1354 fee_consensus: FeeConsensus,
1355 ) -> anyhow::Result<TieredMulti<SpendableNote>> {
1356 let note_stream = dbtx
1357 .find_by_prefix_sorted_descending(&NoteKeyPrefix)
1358 .await
1359 .map(|(key, note)| (key.amount, note));
1360
1361 notes_selector
1362 .select_notes(note_stream, requested_amount, fee_consensus)
1363 .await?
1364 .into_iter_items()
1365 .map(|(amt, snote)| Ok((amt, snote.decode()?)))
1366 .collect::<anyhow::Result<TieredMulti<_>>>()
1367 }
1368
1369 async fn get_all_spendable_notes(
1370 dbtx: &mut DatabaseTransaction<'_>,
1371 ) -> TieredMulti<SpendableNoteUndecoded> {
1372 (dbtx
1373 .find_by_prefix(&NoteKeyPrefix)
1374 .await
1375 .map(|(key, note)| (key.amount, note))
1376 .collect::<Vec<_>>()
1377 .await)
1378 .into_iter()
1379 .collect()
1380 }
1381
1382 async fn get_next_note_index(
1383 &self,
1384 dbtx: &mut DatabaseTransaction<'_>,
1385 amount: Amount,
1386 ) -> NoteIndex {
1387 NoteIndex(
1388 dbtx.get_value(&NextECashNoteIndexKey(amount))
1389 .await
1390 .unwrap_or(0),
1391 )
1392 }
1393
1394 pub fn new_note_secret_static(
1410 secret: &DerivableSecret,
1411 amount: Amount,
1412 note_idx: NoteIndex,
1413 ) -> DerivableSecret {
1414 assert_eq!(secret.level(), 2);
1415 debug!(?secret, %amount, %note_idx, "Deriving new mint note");
1416 secret
1417 .child_key(MINT_E_CASH_TYPE_CHILD_ID) .child_key(ChildId(note_idx.as_u64()))
1419 .child_key(ChildId(amount.msats))
1420 }
1421
1422 async fn new_note_secret(
1426 &self,
1427 amount: Amount,
1428 dbtx: &mut DatabaseTransaction<'_>,
1429 ) -> DerivableSecret {
1430 let new_idx = self.get_next_note_index(dbtx, amount).await;
1431 dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64())
1432 .await;
1433 Self::new_note_secret_static(&self.secret, amount, new_idx)
1434 }
1435
1436 pub async fn new_ecash_note(
1437 &self,
1438 amount: Amount,
1439 dbtx: &mut DatabaseTransaction<'_>,
1440 ) -> (NoteIssuanceRequest, BlindNonce) {
1441 let secret = self.new_note_secret(amount, dbtx).await;
1442 NoteIssuanceRequest::new(&self.secp, &secret)
1443 }
1444
1445 pub async fn reissue_external_notes<M: Serialize + Send>(
1450 &self,
1451 oob_notes: OOBNotes,
1452 extra_meta: M,
1453 ) -> anyhow::Result<OperationId> {
1454 let notes = oob_notes.notes().clone();
1455 let federation_id_prefix = oob_notes.federation_id_prefix();
1456
1457 ensure!(
1458 notes.total_amount() > Amount::ZERO,
1459 "Reissuing zero-amount e-cash isn't supported"
1460 );
1461
1462 if federation_id_prefix != self.federation_id.to_prefix() {
1463 bail!(ReissueExternalNotesError::WrongFederationId);
1464 }
1465
1466 let operation_id = OperationId(
1467 notes
1468 .consensus_hash::<sha256t::Hash<OOBReissueTag>>()
1469 .to_byte_array(),
1470 );
1471
1472 let amount = notes.total_amount();
1473 let mint_inputs = self.create_input_from_notes(notes)?;
1474
1475 let tx = TransactionBuilder::new().with_inputs(
1476 self.client_ctx
1477 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
1478 );
1479
1480 let extra_meta = serde_json::to_value(extra_meta)
1481 .expect("MintClientModule::reissue_external_notes extra_meta is serializable");
1482 let operation_meta_gen = move |change_range: OutPointRange| MintOperationMeta {
1483 variant: MintOperationMetaVariant::Reissuance {
1484 legacy_out_point: None,
1485 txid: Some(change_range.txid()),
1486 out_point_indices: change_range
1487 .into_iter()
1488 .map(|out_point| out_point.out_idx)
1489 .collect(),
1490 },
1491 amount,
1492 extra_meta: extra_meta.clone(),
1493 };
1494
1495 self.client_ctx
1496 .finalize_and_submit_transaction(
1497 operation_id,
1498 MintCommonInit::KIND.as_str(),
1499 operation_meta_gen,
1500 tx,
1501 )
1502 .await
1503 .context(ReissueExternalNotesError::AlreadyReissued)?;
1504 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1505 self.client_ctx
1506 .log_event(&mut dbtx, OOBNotesReissued { amount })
1507 .await;
1508 dbtx.commit_tx().await;
1509
1510 Ok(operation_id)
1511 }
1512
1513 pub async fn subscribe_reissue_external_notes(
1516 &self,
1517 operation_id: OperationId,
1518 ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> {
1519 let operation = self.mint_operation(operation_id).await?;
1520 let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant {
1521 MintOperationMetaVariant::Reissuance {
1522 legacy_out_point,
1523 txid,
1524 out_point_indices,
1525 } => {
1526 let txid = txid
1529 .or(legacy_out_point.map(|out_point| out_point.txid))
1530 .context("Empty reissuance not permitted, this should never happen")?;
1531
1532 let out_points = out_point_indices
1533 .into_iter()
1534 .map(|out_idx| OutPoint { txid, out_idx })
1535 .chain(legacy_out_point)
1536 .collect::<Vec<_>>();
1537
1538 (txid, out_points)
1539 }
1540 MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"),
1541 };
1542
1543 let client_ctx = self.client_ctx.clone();
1544
1545 Ok(self.client_ctx.outcome_or_updates(operation, operation_id, move || {
1546 stream! {
1547 yield ReissueExternalNotesState::Created;
1548
1549 match client_ctx
1550 .transaction_updates(operation_id)
1551 .await
1552 .await_tx_accepted(txid)
1553 .await
1554 {
1555 Ok(()) => {
1556 yield ReissueExternalNotesState::Issuing;
1557 }
1558 Err(e) => {
1559 yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}"));
1560 return;
1561 }
1562 }
1563
1564 for out_point in out_points {
1565 if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await {
1566 yield ReissueExternalNotesState::Failed(e.to_string());
1567 return;
1568 }
1569 }
1570 yield ReissueExternalNotesState::Done;
1571 }}
1572 ))
1573 }
1574
1575 #[deprecated(
1587 since = "0.5.0",
1588 note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior"
1589 )]
1590 pub async fn spend_notes<M: Serialize + Send>(
1591 &self,
1592 min_amount: Amount,
1593 try_cancel_after: Duration,
1594 include_invite: bool,
1595 extra_meta: M,
1596 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1597 self.spend_notes_with_selector(
1598 &SelectNotesWithAtleastAmount,
1599 min_amount,
1600 try_cancel_after,
1601 include_invite,
1602 extra_meta,
1603 )
1604 .await
1605 }
1606
1607 pub async fn spend_notes_with_selector<M: Serialize + Send>(
1623 &self,
1624 notes_selector: &impl NotesSelector,
1625 requested_amount: Amount,
1626 try_cancel_after: Duration,
1627 include_invite: bool,
1628 extra_meta: M,
1629 ) -> anyhow::Result<(OperationId, OOBNotes)> {
1630 let federation_id_prefix = self.federation_id.to_prefix();
1631 let extra_meta = serde_json::to_value(extra_meta)
1632 .expect("MintClientModule::spend_notes extra_meta is serializable");
1633
1634 self.client_ctx
1635 .module_db()
1636 .autocommit(
1637 |dbtx, _| {
1638 let extra_meta = extra_meta.clone();
1639 Box::pin(async {
1640 let (operation_id, states, notes) = self
1641 .spend_notes_oob(
1642 dbtx,
1643 notes_selector,
1644 requested_amount,
1645 try_cancel_after,
1646 )
1647 .await?;
1648
1649 let oob_notes = if include_invite {
1650 OOBNotes::new_with_invite(
1651 notes,
1652 &self.client_ctx.get_invite_code().await,
1653 )
1654 } else {
1655 OOBNotes::new(federation_id_prefix, notes)
1656 };
1657
1658 self.client_ctx
1659 .add_state_machines_dbtx(
1660 dbtx,
1661 self.client_ctx.map_dyn(states).collect(),
1662 )
1663 .await?;
1664 self.client_ctx
1665 .add_operation_log_entry_dbtx(
1666 dbtx,
1667 operation_id,
1668 MintCommonInit::KIND.as_str(),
1669 MintOperationMeta {
1670 variant: MintOperationMetaVariant::SpendOOB {
1671 requested_amount,
1672 oob_notes: oob_notes.clone(),
1673 },
1674 amount: oob_notes.total_amount(),
1675 extra_meta,
1676 },
1677 )
1678 .await;
1679 self.client_ctx
1680 .log_event(
1681 dbtx,
1682 OOBNotesSpent {
1683 requested_amount,
1684 spent_amount: oob_notes.total_amount(),
1685 timeout: try_cancel_after,
1686 include_invite,
1687 },
1688 )
1689 .await;
1690
1691 Ok((operation_id, oob_notes))
1692 })
1693 },
1694 Some(100),
1695 )
1696 .await
1697 .map_err(|e| match e {
1698 AutocommitError::ClosureError { error, .. } => error,
1699 AutocommitError::CommitFailed { last_error, .. } => {
1700 anyhow!("Commit to DB failed: {last_error}")
1701 }
1702 })
1703 }
1704
1705 pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> {
1711 let federation_id_prefix = oob_notes.federation_id_prefix();
1712 let notes = oob_notes.notes().clone();
1713
1714 if federation_id_prefix != self.federation_id.to_prefix() {
1715 bail!("Federation ID does not match");
1716 }
1717
1718 let tbs_pks = &self.cfg.tbs_pks;
1719
1720 for (idx, (amt, snote)) in notes.iter_items().enumerate() {
1721 let key = tbs_pks
1722 .get(amt)
1723 .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?;
1724
1725 let note = snote.note();
1726 if !note.verify(*key) {
1727 bail!("Note {idx} has an invalid federation signature");
1728 }
1729
1730 let expected_nonce = Nonce(snote.spend_key.public_key());
1731 if note.nonce != expected_nonce {
1732 bail!("Note {idx} cannot be spent using the supplied spend key");
1733 }
1734 }
1735
1736 Ok(notes.total_amount())
1737 }
1738
1739 pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) {
1744 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1745 dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &())
1746 .await;
1747 if let Err(e) = dbtx.commit_tx_result().await {
1748 warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}");
1749 }
1750 }
1751
1752 pub async fn subscribe_spend_notes(
1755 &self,
1756 operation_id: OperationId,
1757 ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> {
1758 let operation = self.mint_operation(operation_id).await?;
1759 if !matches!(
1760 operation.meta::<MintOperationMeta>().variant,
1761 MintOperationMetaVariant::SpendOOB { .. }
1762 ) {
1763 bail!("Operation is not a out-of-band spend");
1764 };
1765
1766 let client_ctx = self.client_ctx.clone();
1767
1768 Ok(self
1769 .client_ctx
1770 .outcome_or_updates(operation, operation_id, move || {
1771 stream! {
1772 yield SpendOOBState::Created;
1773
1774 let self_ref = client_ctx.self_ref();
1775
1776 let refund = self_ref
1777 .await_spend_oob_refund(operation_id)
1778 .await;
1779
1780 if refund.user_triggered {
1781 yield SpendOOBState::UserCanceledProcessing;
1782 }
1783
1784 let mut success = true;
1785
1786 for txid in refund.transaction_ids {
1787 debug!(
1788 target: LOG_CLIENT_MODULE_MINT,
1789 %txid,
1790 operation_id=%operation_id.fmt_short(),
1791 "Waiting for oob refund txid"
1792 );
1793 if client_ctx
1794 .transaction_updates(operation_id)
1795 .await
1796 .await_tx_accepted(txid)
1797 .await.is_err() {
1798 success = false;
1799 }
1800 }
1801
1802 debug!(
1803 target: LOG_CLIENT_MODULE_MINT,
1804 operation_id=%operation_id.fmt_short(),
1805 %success,
1806 "Done waiting for all refund oob txids"
1807 );
1808
1809 match (refund.user_triggered, success) {
1810 (true, true) => {
1811 yield SpendOOBState::UserCanceledSuccess;
1812 },
1813 (true, false) => {
1814 yield SpendOOBState::UserCanceledFailure;
1815 },
1816 (false, true) => {
1817 yield SpendOOBState::Refunded;
1818 },
1819 (false, false) => {
1820 yield SpendOOBState::Success;
1821 }
1822 }
1823 }
1824 }))
1825 }
1826
1827 async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> {
1828 let operation = self.client_ctx.get_operation(operation_id).await?;
1829
1830 if operation.operation_module_kind() != MintCommonInit::KIND.as_str() {
1831 bail!("Operation is not a mint operation");
1832 }
1833
1834 Ok(operation)
1835 }
1836
1837 async fn delete_spendable_note(
1838 client_ctx: &ClientContext<MintClientModule>,
1839 dbtx: &mut DatabaseTransaction<'_>,
1840 amount: Amount,
1841 note: &SpendableNote,
1842 ) {
1843 client_ctx
1844 .log_event(
1845 dbtx,
1846 NoteSpent {
1847 nonce: note.nonce(),
1848 },
1849 )
1850 .await;
1851 dbtx.remove_entry(&NoteKey {
1852 amount,
1853 nonce: note.nonce(),
1854 })
1855 .await
1856 .expect("Must deleted existing spendable note");
1857 }
1858
1859 pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> {
1860 let db = self.client_ctx.module_db().clone();
1861
1862 Ok(db
1863 .autocommit(
1864 |dbtx, _| {
1865 Box::pin(async {
1866 Ok::<DerivableSecret, anyhow::Error>(
1867 self.new_note_secret(amount, dbtx).await,
1868 )
1869 })
1870 },
1871 None,
1872 )
1873 .await?)
1874 }
1875
1876 pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> {
1879 self.client_ctx
1880 .module_db()
1881 .begin_transaction_nc()
1882 .await
1883 .get_value(&ReusedNoteIndices)
1884 .await
1885 .unwrap_or_default()
1886 .into_iter()
1887 .map(|(amount, note_idx)| {
1888 let secret = Self::new_note_secret_static(&self.secret, amount, note_idx);
1889 let (request, blind_nonce) =
1890 NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret);
1891 (amount, request, blind_nonce)
1892 })
1893 .collect()
1894 }
1895}
1896
1897pub fn spendable_notes_to_operation_id(
1898 spendable_selected_notes: &TieredMulti<SpendableNote>,
1899) -> OperationId {
1900 OperationId(
1901 spendable_selected_notes
1902 .consensus_hash::<sha256t::Hash<OOBSpendTag>>()
1903 .to_byte_array(),
1904 )
1905}
1906
1907#[derive(Debug, Serialize, Deserialize, Clone)]
1908pub struct SpendOOBRefund {
1909 pub user_triggered: bool,
1910 pub transaction_ids: Vec<TransactionId>,
1911}
1912
1913#[apply(async_trait_maybe_send!)]
1916pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync {
1917 async fn select_notes(
1920 &self,
1921 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
1923 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
1924 requested_amount: Amount,
1925 fee_consensus: FeeConsensus,
1926 ) -> anyhow::Result<TieredMulti<Note>>;
1927}
1928
1929pub struct SelectNotesWithAtleastAmount;
1935
1936#[apply(async_trait_maybe_send!)]
1937impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount {
1938 async fn select_notes(
1939 &self,
1940 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
1941 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
1942 requested_amount: Amount,
1943 fee_consensus: FeeConsensus,
1944 ) -> anyhow::Result<TieredMulti<Note>> {
1945 Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?)
1946 }
1947}
1948
1949pub struct SelectNotesWithExactAmount;
1953
1954#[apply(async_trait_maybe_send!)]
1955impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount {
1956 async fn select_notes(
1957 &self,
1958 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send,
1959 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>,
1960 requested_amount: Amount,
1961 fee_consensus: FeeConsensus,
1962 ) -> anyhow::Result<TieredMulti<Note>> {
1963 let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?;
1964
1965 if notes.total_amount() != requested_amount {
1966 bail!(
1967 "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}",
1968 requested_amount,
1969 notes.total_amount()
1970 );
1971 }
1972
1973 Ok(notes)
1974 }
1975}
1976
1977async fn select_notes_from_stream<Note>(
1983 stream: impl futures::Stream<Item = (Amount, Note)>,
1984 requested_amount: Amount,
1985 fee_consensus: FeeConsensus,
1986) -> Result<TieredMulti<Note>, InsufficientBalanceError> {
1987 if requested_amount == Amount::ZERO {
1988 return Ok(TieredMulti::default());
1989 }
1990 let mut stream = Box::pin(stream);
1991 let mut selected = vec![];
1992 let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None;
1997 let mut pending_amount = requested_amount;
1998 let mut previous_amount: Option<Amount> = None; loop {
2000 if let Some((note_amount, note)) = stream.next().await {
2001 assert!(
2002 previous_amount.is_none_or(|previous| previous >= note_amount),
2003 "notes are not sorted in descending order"
2004 );
2005 previous_amount = Some(note_amount);
2006
2007 if note_amount <= fee_consensus.fee(note_amount) {
2008 continue;
2009 }
2010
2011 match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) {
2012 Ordering::Less => {
2013 pending_amount += fee_consensus.fee(note_amount);
2015 pending_amount -= note_amount;
2016 selected.push((note_amount, note));
2017 }
2018 Ordering::Greater => {
2019 last_big_note_checkpoint = Some((note_amount, note, selected.len()));
2023 }
2024 Ordering::Equal => {
2025 selected.push((note_amount, note));
2027
2028 let notes: TieredMulti<Note> = selected.into_iter().collect();
2029
2030 assert!(
2031 notes.total_amount().msats
2032 >= requested_amount.msats
2033 + notes
2034 .iter()
2035 .map(|note| fee_consensus.fee(note.0))
2036 .sum::<Amount>()
2037 .msats
2038 );
2039
2040 return Ok(notes);
2041 }
2042 }
2043 } else {
2044 assert!(pending_amount > Amount::ZERO);
2045 if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint {
2046 selected.truncate(checkpoint);
2049 selected.push((big_note_amount, big_note));
2051
2052 let notes: TieredMulti<Note> = selected.into_iter().collect();
2053
2054 assert!(
2055 notes.total_amount().msats
2056 >= requested_amount.msats
2057 + notes
2058 .iter()
2059 .map(|note| fee_consensus.fee(note.0))
2060 .sum::<Amount>()
2061 .msats
2062 );
2063
2064 return Ok(notes);
2066 }
2067
2068 let total_amount = requested_amount.saturating_sub(pending_amount);
2069 return Err(InsufficientBalanceError {
2071 requested_amount,
2072 total_amount,
2073 });
2074 }
2075 }
2076}
2077
2078#[derive(Debug, Clone, Error)]
2079pub struct InsufficientBalanceError {
2080 pub requested_amount: Amount,
2081 pub total_amount: Amount,
2082}
2083
2084impl std::fmt::Display for InsufficientBalanceError {
2085 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2086 write!(
2087 f,
2088 "Insufficient balance: requested {} but only {} available",
2089 self.requested_amount, self.total_amount
2090 )
2091 }
2092}
2093
2094#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2096enum MintRestoreStates {
2097 #[encodable_default]
2098 Default { variant: u64, bytes: Vec<u8> },
2099}
2100
2101#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2103pub struct MintRestoreStateMachine {
2104 operation_id: OperationId,
2105 state: MintRestoreStates,
2106}
2107
2108#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2109pub enum MintClientStateMachines {
2110 Output(MintOutputStateMachine),
2111 Input(MintInputStateMachine),
2112 OOB(MintOOBStateMachine),
2113 Restore(MintRestoreStateMachine),
2115}
2116
2117impl IntoDynInstance for MintClientStateMachines {
2118 type DynType = DynState;
2119
2120 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2121 DynState::from_typed(instance_id, self)
2122 }
2123}
2124
2125impl State for MintClientStateMachines {
2126 type ModuleContext = MintClientContext;
2127
2128 fn transitions(
2129 &self,
2130 context: &Self::ModuleContext,
2131 global_context: &DynGlobalClientContext,
2132 ) -> Vec<StateTransition<Self>> {
2133 match self {
2134 MintClientStateMachines::Output(issuance_state) => {
2135 sm_enum_variant_translation!(
2136 issuance_state.transitions(context, global_context),
2137 MintClientStateMachines::Output
2138 )
2139 }
2140 MintClientStateMachines::Input(redemption_state) => {
2141 sm_enum_variant_translation!(
2142 redemption_state.transitions(context, global_context),
2143 MintClientStateMachines::Input
2144 )
2145 }
2146 MintClientStateMachines::OOB(oob_state) => {
2147 sm_enum_variant_translation!(
2148 oob_state.transitions(context, global_context),
2149 MintClientStateMachines::OOB
2150 )
2151 }
2152 MintClientStateMachines::Restore(_) => {
2153 sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore)
2154 }
2155 }
2156 }
2157
2158 fn operation_id(&self) -> OperationId {
2159 match self {
2160 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(),
2161 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(),
2162 MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(),
2163 MintClientStateMachines::Restore(r) => r.operation_id,
2164 }
2165 }
2166}
2167
2168#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)]
2171pub struct SpendableNote {
2172 pub signature: tbs::Signature,
2173 pub spend_key: Keypair,
2174}
2175
2176impl fmt::Debug for SpendableNote {
2177 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2178 f.debug_struct("SpendableNote")
2179 .field("nonce", &self.nonce())
2180 .field("signature", &self.signature)
2181 .field("spend_key", &self.spend_key)
2182 .finish()
2183 }
2184}
2185impl fmt::Display for SpendableNote {
2186 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2187 self.nonce().fmt(f)
2188 }
2189}
2190
2191impl SpendableNote {
2192 pub fn nonce(&self) -> Nonce {
2193 Nonce(self.spend_key.public_key())
2194 }
2195
2196 fn note(&self) -> Note {
2197 Note {
2198 nonce: self.nonce(),
2199 signature: self.signature,
2200 }
2201 }
2202
2203 pub fn to_undecoded(&self) -> SpendableNoteUndecoded {
2204 SpendableNoteUndecoded {
2205 signature: self
2206 .signature
2207 .consensus_encode_to_vec()
2208 .try_into()
2209 .expect("Encoded size always correct"),
2210 spend_key: self.spend_key,
2211 }
2212 }
2213}
2214
2215#[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)]
2227pub struct SpendableNoteUndecoded {
2228 #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")]
2231 pub signature: [u8; 48],
2232 pub spend_key: Keypair,
2233}
2234
2235impl fmt::Display for SpendableNoteUndecoded {
2236 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2237 self.nonce().fmt(f)
2238 }
2239}
2240
2241impl fmt::Debug for SpendableNoteUndecoded {
2242 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2243 f.debug_struct("SpendableNote")
2244 .field("nonce", &self.nonce())
2245 .field("signature", &"[raw]")
2246 .field("spend_key", &self.spend_key)
2247 .finish()
2248 }
2249}
2250
2251impl SpendableNoteUndecoded {
2252 fn nonce(&self) -> Nonce {
2253 Nonce(self.spend_key.public_key())
2254 }
2255
2256 pub fn decode(self) -> anyhow::Result<SpendableNote> {
2257 Ok(SpendableNote {
2258 signature: Decodable::consensus_decode_partial_from_finite_reader(
2259 &mut self.signature.as_slice(),
2260 &ModuleRegistry::default(),
2261 )?,
2262 spend_key: self.spend_key,
2263 })
2264 }
2265}
2266
2267#[derive(
2273 Copy,
2274 Clone,
2275 Debug,
2276 Serialize,
2277 Deserialize,
2278 PartialEq,
2279 Eq,
2280 Encodable,
2281 Decodable,
2282 Default,
2283 PartialOrd,
2284 Ord,
2285)]
2286pub struct NoteIndex(u64);
2287
2288impl NoteIndex {
2289 pub fn next(self) -> Self {
2290 Self(self.0 + 1)
2291 }
2292
2293 fn prev(self) -> Option<Self> {
2294 self.0.checked_sub(0).map(Self)
2295 }
2296
2297 pub fn as_u64(self) -> u64 {
2298 self.0
2299 }
2300
2301 #[allow(unused)]
2305 pub fn from_u64(v: u64) -> Self {
2306 Self(v)
2307 }
2308
2309 pub fn advance(&mut self) {
2310 *self = self.next();
2311 }
2312}
2313
2314impl std::fmt::Display for NoteIndex {
2315 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2316 self.0.fmt(f)
2317 }
2318}
2319
2320struct OOBSpendTag;
2321
2322impl sha256t::Tag for OOBSpendTag {
2323 fn engine() -> sha256::HashEngine {
2324 let mut engine = sha256::HashEngine::default();
2325 engine.input(b"oob-spend");
2326 engine
2327 }
2328}
2329
2330struct OOBReissueTag;
2331
2332impl sha256t::Tag for OOBReissueTag {
2333 fn engine() -> sha256::HashEngine {
2334 let mut engine = sha256::HashEngine::default();
2335 engine.input(b"oob-reissue");
2336 engine
2337 }
2338}
2339
2340pub fn represent_amount<K>(
2346 amount: Amount,
2347 current_denominations: &TieredCounts,
2348 tiers: &Tiered<K>,
2349 denomination_sets: u16,
2350 fee_consensus: &FeeConsensus,
2351) -> TieredCounts {
2352 let mut remaining_amount = amount;
2353 let mut denominations = TieredCounts::default();
2354
2355 for tier in tiers.tiers() {
2357 let notes = current_denominations.get(*tier);
2358 let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64);
2359 let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier));
2360
2361 let add_notes = min(possible_notes, missing_notes);
2362 denominations.inc(*tier, add_notes as usize);
2363 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes;
2364 }
2365
2366 for tier in tiers.tiers().rev() {
2368 let res = remaining_amount / (*tier + fee_consensus.fee(*tier));
2369 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res;
2370 denominations.inc(*tier, res as usize);
2371 }
2372
2373 let represented: u64 = denominations
2374 .iter()
2375 .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64))
2376 .sum();
2377
2378 assert!(represented <= amount.msats);
2379 assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats);
2380
2381 denominations
2382}
2383
2384pub(crate) fn create_bundle_for_inputs(
2385 inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>,
2386 operation_id: OperationId,
2387) -> ClientInputBundle<MintInput, MintClientStateMachines> {
2388 let mut inputs = Vec::new();
2389 let mut input_states = Vec::new();
2390
2391 for (input, spendable_note) in inputs_and_notes {
2392 input_states.push((input.amount, spendable_note));
2393 inputs.push(input);
2394 }
2395
2396 let input_sm = Arc::new(move |out_point_range: OutPointRange| {
2397 debug_assert_eq!(out_point_range.into_iter().count(), input_states.len());
2398
2399 vec![MintClientStateMachines::Input(MintInputStateMachine {
2400 common: MintInputCommon {
2401 operation_id,
2402 out_point_range,
2403 },
2404 state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle {
2405 notes: input_states.clone(),
2406 }),
2407 })]
2408 });
2409
2410 ClientInputBundle::new(
2411 inputs,
2412 vec![ClientInputSM {
2413 state_machines: input_sm,
2414 }],
2415 )
2416}
2417
2418#[cfg(test)]
2419mod tests {
2420 use std::fmt::Display;
2421 use std::str::FromStr;
2422
2423 use bitcoin_hashes::Hash;
2424 use fedimint_core::config::FederationId;
2425 use fedimint_core::encoding::Decodable;
2426 use fedimint_core::invite_code::InviteCode;
2427 use fedimint_core::module::registry::ModuleRegistry;
2428 use fedimint_core::{
2429 Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId,
2430 };
2431 use fedimint_mint_common::config::FeeConsensus;
2432 use itertools::Itertools;
2433 use serde_json::json;
2434
2435 use crate::{
2436 MintOperationMetaVariant, OOBNotes, OOBNotesPart, SpendableNote, SpendableNoteUndecoded,
2437 represent_amount, select_notes_from_stream,
2438 };
2439
2440 #[test]
2441 fn represent_amount_targets_denomination_sets() {
2442 fn tiers(tiers: Vec<u64>) -> Tiered<()> {
2443 tiers
2444 .into_iter()
2445 .map(|tier| (Amount::from_sats(tier), ()))
2446 .collect()
2447 }
2448
2449 fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts {
2450 TieredCounts::from_iter(denominations)
2451 }
2452
2453 let starting = notes(vec![
2454 (Amount::from_sats(1), 1),
2455 (Amount::from_sats(2), 3),
2456 (Amount::from_sats(3), 2),
2457 ])
2458 .summary();
2459 let tiers = tiers(vec![1, 2, 3, 4]);
2460
2461 assert_eq!(
2463 represent_amount(
2464 Amount::from_sats(6),
2465 &starting,
2466 &tiers,
2467 3,
2468 &FeeConsensus::zero()
2469 ),
2470 denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),])
2471 );
2472
2473 assert_eq!(
2475 represent_amount(
2476 Amount::from_sats(6),
2477 &starting,
2478 &tiers,
2479 2,
2480 &FeeConsensus::zero()
2481 ),
2482 denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)])
2483 );
2484 }
2485
2486 #[test_log::test(tokio::test)]
2487 async fn select_notes_avg_test() {
2488 let max_amount = Amount::from_sats(1_000_000);
2489 let tiers = Tiered::gen_denominations(2, max_amount);
2490 let tiered = represent_amount::<()>(
2491 max_amount,
2492 &TieredCounts::default(),
2493 &tiers,
2494 3,
2495 &FeeConsensus::zero(),
2496 );
2497
2498 let mut total_notes = 0;
2499 for multiplier in 1..100 {
2500 let stream = reverse_sorted_note_stream(tiered.iter().collect());
2501 let select = select_notes_from_stream(
2502 stream,
2503 Amount::from_sats(multiplier * 1000),
2504 FeeConsensus::zero(),
2505 )
2506 .await;
2507 total_notes += select.unwrap().into_iter_items().count();
2508 }
2509 assert_eq!(total_notes / 100, 10);
2510 }
2511
2512 #[test_log::test(tokio::test)]
2513 async fn select_notes_returns_exact_amount_with_minimum_notes() {
2514 let f = || {
2515 reverse_sorted_note_stream(vec![
2516 (Amount::from_sats(1), 10),
2517 (Amount::from_sats(5), 10),
2518 (Amount::from_sats(20), 10),
2519 ])
2520 };
2521 assert_eq!(
2522 select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero())
2523 .await
2524 .unwrap(),
2525 notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)])
2526 );
2527 assert_eq!(
2528 select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero())
2529 .await
2530 .unwrap(),
2531 notes(vec![(Amount::from_sats(20), 1)])
2532 );
2533 }
2534
2535 #[test_log::test(tokio::test)]
2536 async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() {
2537 let stream = reverse_sorted_note_stream(vec![
2538 (Amount::from_sats(1), 1),
2539 (Amount::from_sats(5), 5),
2540 (Amount::from_sats(20), 5),
2541 ]);
2542 assert_eq!(
2543 select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero())
2544 .await
2545 .unwrap(),
2546 notes(vec![(Amount::from_sats(5), 2)])
2547 );
2548 }
2549
2550 #[test_log::test(tokio::test)]
2551 async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() {
2552 let stream = reverse_sorted_note_stream(vec![
2553 (Amount::from_sats(1), 3),
2554 (Amount::from_sats(5), 3),
2555 (Amount::from_sats(20), 2),
2556 ]);
2557 assert_eq!(
2558 select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero())
2559 .await
2560 .unwrap(),
2561 notes(vec![(Amount::from_sats(20), 2)])
2562 );
2563 }
2564
2565 #[test_log::test(tokio::test)]
2566 async fn select_notes_returns_error_if_amount_is_too_large() {
2567 let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]);
2568 let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero())
2569 .await
2570 .unwrap_err();
2571 assert_eq!(error.total_amount, Amount::from_sats(10));
2572 }
2573
2574 fn reverse_sorted_note_stream(
2575 notes: Vec<(Amount, usize)>,
2576 ) -> impl futures::Stream<Item = (Amount, String)> {
2577 futures::stream::iter(
2578 notes
2579 .into_iter()
2580 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2582 .sorted()
2583 .rev(),
2584 )
2585 }
2586
2587 fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> {
2588 notes
2589 .into_iter()
2590 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number])
2591 .collect()
2592 }
2593
2594 #[test]
2595 fn decoding_empty_oob_notes_fails() {
2596 let empty_oob_notes =
2597 OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default());
2598 let oob_notes_string = empty_oob_notes.to_string();
2599
2600 let res = oob_notes_string.parse::<OOBNotes>();
2601
2602 assert!(res.is_err(), "An empty OOB notes string should not parse");
2603 }
2604
2605 fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F)
2606 where
2607 T: FromStr + Display,
2608 <T as FromStr>::Err: std::fmt::Debug,
2609 F: Fn(T),
2610 {
2611 let data_str = data.to_string();
2612 assertions(data);
2613 let data_parsed = data_str.parse().expect("Deserialization failed");
2614 assertions(data_parsed);
2615 }
2616
2617 #[test]
2618 fn notes_encode_decode() {
2619 let federation_id_1 =
2620 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32]));
2621 let federation_id_prefix_1 = federation_id_1.to_prefix();
2622 let federation_id_2 =
2623 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32]));
2624 let federation_id_prefix_2 = federation_id_2.to_prefix();
2625
2626 let notes = vec![(
2627 Amount::from_sats(1),
2628 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(),
2629 )]
2630 .into_iter()
2631 .collect::<TieredMulti<_>>();
2632
2633 let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone());
2635 test_roundtrip_serialize_str(notes_no_invite, |oob_notes| {
2636 assert_eq!(oob_notes.notes(), ¬es);
2637 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2638 assert_eq!(oob_notes.federation_invite(), None);
2639 });
2640
2641 let invite = InviteCode::new(
2643 "wss://foo.bar".parse().unwrap(),
2644 PeerId::from(0),
2645 federation_id_1,
2646 None,
2647 );
2648 let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite);
2649 test_roundtrip_serialize_str(notes_invite, |oob_notes| {
2650 assert_eq!(oob_notes.notes(), ¬es);
2651 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2652 assert_eq!(oob_notes.federation_invite(), Some(invite.clone()));
2653 });
2654
2655 let notes_no_prefix = OOBNotes(vec![
2658 OOBNotesPart::Notes(notes.clone()),
2659 OOBNotesPart::Invite {
2660 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2661 federation_id: federation_id_1,
2662 },
2663 ]);
2664 test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| {
2665 assert_eq!(oob_notes.notes(), ¬es);
2666 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1);
2667 });
2668
2669 let notes_inconsistent = OOBNotes(vec![
2671 OOBNotesPart::Notes(notes),
2672 OOBNotesPart::Invite {
2673 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())],
2674 federation_id: federation_id_1,
2675 },
2676 OOBNotesPart::FederationIdPrefix(federation_id_prefix_2),
2677 ]);
2678 let notes_inconsistent_str = notes_inconsistent.to_string();
2679 assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err());
2680 }
2681
2682 #[test]
2683 fn spendable_note_undecoded_sanity() {
2684 #[allow(clippy::single_element_loop)]
2686 for note_hex in [
2687 "a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd",
2688 ] {
2689 let note =
2690 SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap();
2691 let note_undecoded =
2692 SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default())
2693 .unwrap()
2694 .decode()
2695 .unwrap();
2696 assert_eq!(note, note_undecoded,);
2697 assert_eq!(
2698 serde_json::to_string(¬e).unwrap(),
2699 serde_json::to_string(¬e_undecoded).unwrap(),
2700 );
2701 }
2702 }
2703
2704 #[test]
2705 fn reissuance_meta_compatibility_02_03() {
2706 let dummy_outpoint = OutPoint {
2707 txid: TransactionId::all_zeros(),
2708 out_idx: 0,
2709 };
2710
2711 let old_meta_json = json!({
2712 "reissuance": {
2713 "out_point": dummy_outpoint
2714 }
2715 });
2716
2717 let old_meta: MintOperationMetaVariant =
2718 serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed");
2719 assert_eq!(
2720 old_meta,
2721 MintOperationMetaVariant::Reissuance {
2722 legacy_out_point: Some(dummy_outpoint),
2723 txid: None,
2724 out_point_indices: vec![],
2725 }
2726 );
2727
2728 let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance {
2729 legacy_out_point: None,
2730 txid: Some(dummy_outpoint.txid),
2731 out_point_indices: vec![0],
2732 })
2733 .expect("serializing always works");
2734 assert_eq!(
2735 new_meta_json,
2736 json!({
2737 "reissuance": {
2738 "txid": dummy_outpoint.txid,
2739 "out_point_indices": [dummy_outpoint.out_idx],
2740 }
2741 })
2742 );
2743 }
2744}