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