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