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