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