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