lib.rs
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 9 // Backup and restore logic 10 pub mod backup; 11 /// Modularized Cli for sending and receiving out-of-band ecash 12 #[cfg(feature = "cli")] 13 mod cli; 14 /// Database keys used throughout the mint client module 15 pub mod client_db; 16 /// State machines for mint inputs 17 mod input; 18 /// State machines for out-of-band transmitted e-cash notes 19 mod oob; 20 /// State machines for mint outputs 21 pub mod output; 22 23 pub mod event; 24 25 /// API client impl for mint-specific requests 26 pub mod api; 27 28 use std::cmp::{min, Ordering}; 29 use std::collections::BTreeMap; 30 use std::fmt; 31 use std::fmt::{Display, Formatter}; 32 use std::io::Read; 33 use std::str::FromStr; 34 use std::sync::Arc; 35 use std::time::Duration; 36 37 use anyhow::{anyhow, bail, ensure, Context as _}; 38 use async_stream::{stream, try_stream}; 39 use backup::recovery::MintRecovery; 40 use base64::Engine as _; 41 use bitcoin_hashes::{sha256, sha256t, Hash, HashEngine as BitcoinHashEngine}; 42 use client_db::{ 43 migrate_state_to_v2, migrate_to_v1, DbKeyPrefix, NoteKeyPrefix, RecoveryFinalizedKey, 44 ReusedNoteIndices, 45 }; 46 use event::{NoteSpent, OOBNotesReissued, OOBNotesSpent}; 47 use fedimint_client::db::{migrate_state, ClientMigrationFn}; 48 use fedimint_client::module::init::{ 49 ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs, 50 }; 51 use fedimint_client::module::{ClientContext, ClientModule, IClientModule, OutPointRange}; 52 use fedimint_client::oplog::{OperationLogEntry, UpdateStreamOrOutcome}; 53 use fedimint_client::sm::util::MapStateTransitions; 54 use fedimint_client::sm::{Context, DynState, ModuleNotifier, State, StateTransition}; 55 use fedimint_client::transaction::{ 56 ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle, 57 ClientOutputSM, TransactionBuilder, 58 }; 59 use fedimint_client::{sm_enum_variant_translation, DynGlobalClientContext}; 60 use fedimint_core::config::{FederationId, FederationIdPrefix}; 61 use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId}; 62 use fedimint_core::db::{ 63 AutocommitError, Database, DatabaseTransaction, DatabaseVersion, 64 IDatabaseTransactionOpsCoreTyped, 65 }; 66 use fedimint_core::encoding::{Decodable, DecodeError, Encodable}; 67 use fedimint_core::invite_code::{InviteCode, InviteCodeV2}; 68 use fedimint_core::module::registry::{ModuleDecoderRegistry, ModuleRegistry}; 69 use fedimint_core::module::{ 70 ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion, 71 }; 72 use fedimint_core::secp256k1::{All, Keypair, Secp256k1}; 73 use fedimint_core::util::{BoxFuture, BoxStream, NextOrPending, SafeUrl}; 74 use fedimint_core::{ 75 apply, async_trait_maybe_send, push_db_pair_items, Amount, OutPoint, PeerId, Tiered, 76 TieredCounts, TieredMulti, TransactionId, 77 }; 78 use fedimint_derive_secret::{ChildId, DerivableSecret}; 79 use fedimint_logging::LOG_CLIENT_MODULE_MINT; 80 pub use fedimint_mint_common as common; 81 use fedimint_mint_common::config::{FeeConsensus, MintClientConfig}; 82 pub use fedimint_mint_common::*; 83 use futures::{pin_mut, StreamExt}; 84 use hex::ToHex; 85 use input::MintInputStateCreatedBundle; 86 use itertools::Itertools as _; 87 use oob::MintOOBStatesCreatedMulti; 88 use output::MintOutputStatesCreatedMulti; 89 use serde::{Deserialize, Serialize}; 90 use strum::IntoEnumIterator; 91 use tbs::{AggregatePublicKey, Signature}; 92 use thiserror::Error; 93 use tracing::{debug, warn}; 94 95 use crate::backup::EcashBackup; 96 use crate::client_db::{ 97 CancelledOOBSpendKey, CancelledOOBSpendKeyPrefix, NextECashNoteIndexKey, 98 NextECashNoteIndexKeyPrefix, NoteKey, 99 }; 100 use crate::input::{MintInputCommon, MintInputStateMachine, MintInputStates}; 101 use crate::oob::{MintOOBStateMachine, MintOOBStates}; 102 use crate::output::{ 103 MintOutputCommon, MintOutputStateMachine, MintOutputStates, NoteIssuanceRequest, 104 }; 105 106 const MINT_E_CASH_TYPE_CHILD_ID: ChildId = ChildId(0); 107 108 /// An encapsulation of [`FederationId`] and e-cash notes in the form of 109 /// [`TieredMulti<SpendableNote>`] for the purpose of spending e-cash 110 /// out-of-band. Also used for validating and reissuing such out-of-band notes. 111 /// 112 /// ## Invariants 113 /// * Has to contain at least one `Notes` item 114 /// * Has to contain at least one `FederationIdPrefix` item 115 #[derive(Clone, Debug, Encodable, PartialEq, Eq)] 116 pub struct OOBNotes(Vec<OOBNotesPart>); 117 118 /// For extendability [`OOBNotes`] consists of parts, where client can ignore 119 /// ones they don't understand. 120 #[derive(Clone, Debug, Decodable, Encodable, PartialEq, Eq)] 121 enum OOBNotesPart { 122 Notes(TieredMulti<SpendableNote>), 123 FederationIdPrefix(FederationIdPrefix), 124 /// Invite code to join the federation by which the e-cash was issued 125 /// 126 /// Introduced in 0.3.0 127 Invite { 128 // This is a vec for future-proofness, in case we want to include multiple guardian APIs 129 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 140 impl 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 // FIXME: once we can break compatibility with 0.2 we can remove the prefix in case an 154 // invite is present 155 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(_) => { /* already covered inside `Invite` */ } 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 265 impl 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 // TODO: maybe write some macros for defining TLV structs? 273 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 321 const BASE64_URL_SAFE: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new( 322 &base64::alphabet::URL_SAFE, 323 base64::engine::general_purpose::PAD, 324 ); 325 326 impl FromStr for OOBNotes { 327 type Err = anyhow::Error; 328 329 /// Decode a set of out-of-band e-cash notes from a base64 string. 330 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 353 impl Display for OOBNotes { 354 /// Base64 encode a set of e-cash notes for out-of-band spending. 355 /// 356 /// Defaults to standard base64 for backwards compatibility. 357 /// For URL-safe base64 as alternative display use: 358 /// `format!("{:#}", oob_notes)` 359 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 360 let bytes = Encodable::consensus_encode_to_vec(self); 361 362 if f.alternate() { 363 f.write_str(&BASE64_URL_SAFE.encode(&bytes)) 364 } else { 365 f.write_str(&base64::engine::general_purpose::STANDARD.encode(&bytes)) 366 } 367 } 368 } 369 370 impl Serialize for OOBNotes { 371 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 372 where 373 S: serde::Serializer, 374 { 375 serializer.serialize_str(&self.to_string()) 376 } 377 } 378 379 impl<'de> Deserialize<'de> for OOBNotes { 380 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 381 where 382 D: serde::Deserializer<'de>, 383 { 384 let s = String::deserialize(deserializer)?; 385 FromStr::from_str(&s).map_err(serde::de::Error::custom) 386 } 387 } 388 389 impl OOBNotes { 390 /// Returns the total value of all notes in msat as `Amount` 391 pub fn total_amount(&self) -> Amount { 392 self.notes().total_amount() 393 } 394 } 395 396 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Encodable, Decodable)] 397 pub struct OOBNoteV2 { 398 pub amount: Amount, 399 pub sig: Signature, 400 pub key: Keypair, 401 } 402 403 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encodable, Decodable)] 404 pub struct OOBNotesV2 { 405 pub mint: InviteCodeV2, 406 pub notes: Vec<OOBNoteV2>, 407 pub memo: String, 408 } 409 410 impl OOBNotesV2 { 411 pub fn into_v1(self) -> anyhow::Result<OOBNotes> { 412 let notes: TieredMulti<SpendableNote> = self 413 .notes 414 .iter() 415 .map(|n| { 416 ( 417 n.amount, 418 SpendableNote { 419 signature: n.sig, 420 spend_key: n.key, 421 }, 422 ) 423 }) 424 .collect(); 425 426 Ok(OOBNotes::new_with_invite(notes, &self.mint.into_v1()?)) 427 } 428 pub fn total_amount(&self) -> Amount { 429 self.notes.iter().map(|note| note.amount).sum() 430 } 431 432 pub fn encode_base64(&self) -> String { 433 let json = &serde_json::to_string(self).expect("Encoding to JSON cannot fail"); 434 let base_64 = base64_url::encode(json); 435 436 format!("fedimintA{base_64}") 437 } 438 439 pub fn decode_base64(s: &str) -> anyhow::Result<Self> { 440 ensure!(s.starts_with("fedimintA"), "Invalid Prefix"); 441 442 let notes: Self = serde_json::from_slice(&base64_url::decode(&s[9..])?)?; 443 444 ensure!(!notes.mint.peers.is_empty(), "Invite code has no peer"); 445 446 Ok(notes) 447 } 448 } 449 450 /// The high-level state of a reissue operation started with 451 /// [`MintClientModule::reissue_external_notes`]. 452 #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 453 pub enum ReissueExternalNotesState { 454 /// The operation has been created and is waiting to be accepted by the 455 /// federation. 456 Created, 457 /// We are waiting for blind signatures to arrive but can already assume the 458 /// transaction to be successful. 459 Issuing, 460 /// The operation has been completed successfully. 461 Done, 462 /// Some error happened and the operation failed. 463 Failed(String), 464 } 465 466 /// The high-level state of a raw e-cash spend operation started with 467 /// [`MintClientModule::spend_notes_with_selector`]. 468 #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 469 pub enum SpendOOBState { 470 /// The e-cash has been selected and given to the caller 471 Created, 472 /// The user requested a cancellation of the operation, we are waiting for 473 /// the outcome of the cancel transaction. 474 UserCanceledProcessing, 475 /// The user-requested cancellation was successful, we got all our money 476 /// back. 477 UserCanceledSuccess, 478 /// The user-requested cancellation failed, the e-cash notes have been spent 479 /// by someone else already. 480 UserCanceledFailure, 481 /// We tried to cancel the operation automatically after the timeout but 482 /// failed, indicating the recipient reissued the e-cash to themselves, 483 /// making the out-of-band spend **successful**. 484 Success, 485 /// We tried to cancel the operation automatically after the timeout and 486 /// succeeded, indicating the recipient did not reissue the e-cash to 487 /// themselves, meaning the out-of-band spend **failed**. 488 Refunded, 489 } 490 491 #[derive(Debug, Clone, Serialize, Deserialize)] 492 pub struct MintOperationMeta { 493 pub variant: MintOperationMetaVariant, 494 pub amount: Amount, 495 pub extra_meta: serde_json::Value, 496 } 497 498 #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] 499 #[serde(rename_all = "snake_case")] 500 pub enum MintOperationMetaVariant { 501 // TODO: add migrations for operation log and clean up schema 502 /// Either `legacy_out_point` or both `txid` and `out_point_indices` will be 503 /// present. 504 Reissuance { 505 // Removed in 0.3.0: 506 #[serde(skip_serializing, default, rename = "out_point")] 507 legacy_out_point: Option<OutPoint>, 508 // Introduced in 0.3.0: 509 #[serde(default)] 510 txid: Option<TransactionId>, 511 // Introduced in 0.3.0: 512 #[serde(default)] 513 out_point_indices: Vec<u64>, 514 }, 515 SpendOOB { 516 requested_amount: Amount, 517 oob_notes: OOBNotes, 518 }, 519 } 520 521 #[derive(Debug, Clone)] 522 pub struct MintClientInit; 523 524 impl ModuleInit for MintClientInit { 525 type Common = MintCommonInit; 526 527 async fn dump_database( 528 &self, 529 dbtx: &mut DatabaseTransaction<'_>, 530 prefix_names: Vec<String>, 531 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> { 532 let mut mint_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> = 533 BTreeMap::new(); 534 let filtered_prefixes = DbKeyPrefix::iter().filter(|f| { 535 prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase()) 536 }); 537 538 for table in filtered_prefixes { 539 match table { 540 DbKeyPrefix::Note => { 541 push_db_pair_items!( 542 dbtx, 543 NoteKeyPrefix, 544 NoteKey, 545 SpendableNoteUndecoded, 546 mint_client_items, 547 "Notes" 548 ); 549 } 550 DbKeyPrefix::NextECashNoteIndex => { 551 push_db_pair_items!( 552 dbtx, 553 NextECashNoteIndexKeyPrefix, 554 NextECashNoteIndexKey, 555 u64, 556 mint_client_items, 557 "NextECashNoteIndex" 558 ); 559 } 560 DbKeyPrefix::CancelledOOBSpend => { 561 push_db_pair_items!( 562 dbtx, 563 CancelledOOBSpendKeyPrefix, 564 CancelledOOBSpendKey, 565 (), 566 mint_client_items, 567 "CancelledOOBSpendKey" 568 ); 569 } 570 DbKeyPrefix::RecoveryFinalized => { 571 if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await { 572 mint_client_items.insert("RecoveryFinalized".to_string(), Box::new(val)); 573 } 574 } 575 DbKeyPrefix::RecoveryState 576 | DbKeyPrefix::ReusedNoteIndices 577 | DbKeyPrefix::ExternalReservedStart 578 | DbKeyPrefix::CoreInternalReservedStart 579 | DbKeyPrefix::CoreInternalReservedEnd => {} 580 } 581 } 582 583 Box::new(mint_client_items.into_iter()) 584 } 585 } 586 587 #[apply(async_trait_maybe_send!)] 588 impl ClientModuleInit for MintClientInit { 589 type Module = MintClientModule; 590 591 fn supported_api_versions(&self) -> MultiApiVersion { 592 MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }]) 593 .expect("no version conflicts") 594 } 595 596 async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> { 597 Ok(MintClientModule { 598 federation_id: *args.federation_id(), 599 cfg: args.cfg().clone(), 600 secret: args.module_root_secret().clone(), 601 secp: Secp256k1::new(), 602 notifier: args.notifier().clone(), 603 client_ctx: args.context(), 604 }) 605 } 606 607 async fn recover( 608 &self, 609 args: &ClientModuleRecoverArgs<Self>, 610 snapshot: Option<&<Self::Module as ClientModule>::Backup>, 611 ) -> anyhow::Result<()> { 612 args.recover_from_history::<MintRecovery>(self, snapshot) 613 .await 614 } 615 616 fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientMigrationFn> { 617 let mut migrations: BTreeMap<DatabaseVersion, ClientMigrationFn> = BTreeMap::new(); 618 migrations.insert(DatabaseVersion(0), |dbtx, _, _| { 619 Box::pin(migrate_to_v1(dbtx)) 620 }); 621 migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| { 622 Box::pin(async { migrate_state(active_states, inactive_states, migrate_state_to_v2) }) 623 }); 624 625 migrations 626 } 627 } 628 629 /// The `MintClientModule` is responsible for handling e-cash minting 630 /// operations. It interacts with the mint server to issue, reissue, and 631 /// validate e-cash notes. 632 /// 633 /// # Derivable Secret 634 /// 635 /// The `DerivableSecret` is a cryptographic secret that can be used to derive 636 /// other secrets. In the context of the `MintClientModule`, it is used to 637 /// derive the blinding and spend keys for e-cash notes. The `DerivableSecret` 638 /// is initialized when the `MintClientModule` is created and is kept private 639 /// within the module. 640 /// 641 /// # Blinding Key 642 /// 643 /// The blinding key is derived from the `DerivableSecret` and is used to blind 644 /// the e-cash note during the issuance process. This ensures that the mint 645 /// server cannot link the e-cash note to the client that requested it, 646 /// providing privacy for the client. 647 /// 648 /// # Spend Key 649 /// 650 /// The spend key is also derived from the `DerivableSecret` and is used to 651 /// spend the e-cash note. Only the client that possesses the `DerivableSecret` 652 /// can derive the correct spend key to spend the e-cash note. This ensures that 653 /// only the owner of the e-cash note can spend it. 654 #[derive(Debug)] 655 pub struct MintClientModule { 656 federation_id: FederationId, 657 cfg: MintClientConfig, 658 secret: DerivableSecret, 659 secp: Secp256k1<All>, 660 notifier: ModuleNotifier<MintClientStateMachines>, 661 pub client_ctx: ClientContext<Self>, 662 } 663 664 // TODO: wrap in Arc 665 #[derive(Debug, Clone)] 666 pub struct MintClientContext { 667 pub client_ctx: ClientContext<MintClientModule>, 668 pub mint_decoder: Decoder, 669 pub tbs_pks: Tiered<AggregatePublicKey>, 670 pub peer_tbs_pks: BTreeMap<PeerId, Tiered<tbs::PublicKeyShare>>, 671 pub secret: DerivableSecret, 672 // FIXME: putting a DB ref here is an antipattern, global context should become more powerful 673 // but we need to consider it more carefully as its APIs will be harder to change. 674 pub module_db: Database, 675 } 676 677 impl MintClientContext { 678 fn await_cancel_oob_payment(&self, operation_id: OperationId) -> BoxFuture<'static, ()> { 679 let db = self.module_db.clone(); 680 Box::pin(async move { 681 db.wait_key_exists(&CancelledOOBSpendKey(operation_id)) 682 .await; 683 }) 684 } 685 } 686 687 impl Context for MintClientContext { 688 const KIND: Option<ModuleKind> = Some(KIND); 689 } 690 691 #[apply(async_trait_maybe_send!)] 692 impl ClientModule for MintClientModule { 693 type Init = MintClientInit; 694 type Common = MintModuleTypes; 695 type Backup = EcashBackup; 696 type ModuleStateMachineContext = MintClientContext; 697 type States = MintClientStateMachines; 698 699 fn context(&self) -> Self::ModuleStateMachineContext { 700 MintClientContext { 701 client_ctx: self.client_ctx.clone(), 702 mint_decoder: self.decoder(), 703 tbs_pks: self.cfg.tbs_pks.clone(), 704 peer_tbs_pks: self.cfg.peer_tbs_pks.clone(), 705 secret: self.secret.clone(), 706 module_db: self.client_ctx.module_db().clone(), 707 } 708 } 709 710 fn input_fee( 711 &self, 712 amount: Amount, 713 _input: &<Self::Common as ModuleCommon>::Input, 714 ) -> Option<Amount> { 715 Some(self.cfg.fee_consensus.fee(amount)) 716 } 717 718 fn output_fee( 719 &self, 720 amount: Amount, 721 _output: &<Self::Common as ModuleCommon>::Output, 722 ) -> Option<Amount> { 723 Some(self.cfg.fee_consensus.fee(amount)) 724 } 725 726 #[cfg(feature = "cli")] 727 async fn handle_cli_command( 728 &self, 729 args: &[std::ffi::OsString], 730 ) -> anyhow::Result<serde_json::Value> { 731 cli::handle_cli_command(self, args).await 732 } 733 734 fn supports_backup(&self) -> bool { 735 true 736 } 737 738 async fn backup(&self) -> anyhow::Result<EcashBackup> { 739 self.client_ctx 740 .module_db() 741 .autocommit( 742 |dbtx_ctx, _| { 743 Box::pin(async { self.prepare_plaintext_ecash_backup(dbtx_ctx).await }) 744 }, 745 None, 746 ) 747 .await 748 .map_err(|e| match e { 749 AutocommitError::ClosureError { error, .. } => error, 750 AutocommitError::CommitFailed { last_error, .. } => { 751 anyhow!("Commit to DB failed: {last_error}") 752 } 753 }) 754 } 755 756 fn supports_being_primary(&self) -> bool { 757 true 758 } 759 760 async fn create_final_inputs_and_outputs( 761 &self, 762 dbtx: &mut DatabaseTransaction<'_>, 763 operation_id: OperationId, 764 mut input_amount: Amount, 765 mut output_amount: Amount, 766 ) -> anyhow::Result<( 767 ClientInputBundle<MintInput, MintClientStateMachines>, 768 ClientOutputBundle<MintOutput, MintClientStateMachines>, 769 )> { 770 let consolidation_inputs = self.consolidate_notes(dbtx).await?; 771 772 input_amount += consolidation_inputs 773 .iter() 774 .map(|input| input.0.amount) 775 .sum(); 776 777 output_amount += consolidation_inputs 778 .iter() 779 .map(|input| self.cfg.fee_consensus.fee(input.0.amount)) 780 .sum(); 781 782 let additional_inputs = self 783 .create_sufficient_input(dbtx, output_amount.saturating_sub(input_amount)) 784 .await?; 785 786 input_amount += additional_inputs.iter().map(|input| input.0.amount).sum(); 787 788 output_amount += additional_inputs 789 .iter() 790 .map(|input| self.cfg.fee_consensus.fee(input.0.amount)) 791 .sum(); 792 793 let outputs = self 794 .create_output( 795 dbtx, 796 operation_id, 797 2, 798 input_amount.saturating_sub(output_amount), 799 ) 800 .await; 801 802 Ok(( 803 create_bundle_for_inputs( 804 [consolidation_inputs, additional_inputs].concat(), 805 operation_id, 806 ), 807 outputs, 808 )) 809 } 810 811 async fn await_primary_module_output( 812 &self, 813 operation_id: OperationId, 814 out_point: OutPoint, 815 ) -> anyhow::Result<()> { 816 self.await_output_finalized(operation_id, out_point).await 817 } 818 819 async fn get_balance(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amount { 820 self.get_note_counts_by_denomination(dbtx) 821 .await 822 .total_amount() 823 } 824 825 async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> { 826 Box::pin( 827 self.notifier 828 .subscribe_all_operations() 829 .filter_map(|state| async move { 830 #[allow(deprecated)] 831 match state { 832 MintClientStateMachines::Output(MintOutputStateMachine { 833 state: MintOutputStates::Succeeded(_), 834 .. 835 }) 836 | MintClientStateMachines::Input(MintInputStateMachine { 837 state: MintInputStates::Created(_) | MintInputStates::CreatedBundle(_), 838 .. 839 }) 840 | MintClientStateMachines::OOB(MintOOBStateMachine { 841 state: MintOOBStates::Created(_), 842 .. 843 }) => Some(()), 844 _ => None, 845 } 846 }), 847 ) 848 } 849 850 async fn leave(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> { 851 let balance = ClientModule::get_balance(self, dbtx).await; 852 if Amount::from_sats(0) < balance { 853 bail!("Outstanding balance: {balance}"); 854 } 855 856 if !self.client_ctx.get_own_active_states().await.is_empty() { 857 bail!("Pending operations") 858 } 859 Ok(()) 860 } 861 async fn handle_rpc( 862 &self, 863 method: String, 864 request: serde_json::Value, 865 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> { 866 Box::pin(try_stream! { 867 match method.as_str() { 868 "reissue_external_notes" => { 869 let req: ReissueExternalNotesRequest = serde_json::from_value(request)?; 870 let result = self.reissue_external_notes(req.oob_notes, req.extra_meta).await?; 871 yield serde_json::to_value(result)?; 872 } 873 "subscribe_reissue_external_notes" => { 874 let req: SubscribeReissueExternalNotesRequest = serde_json::from_value(request)?; 875 let stream = self.subscribe_reissue_external_notes(req.operation_id).await?; 876 for await state in stream.into_stream() { 877 yield serde_json::to_value(state)?; 878 } 879 } 880 "spend_notes" => { 881 let req: SpendNotesRequest = serde_json::from_value(request)?; 882 let result = self.spend_notes_with_selector( 883 &SelectNotesWithExactAmount, 884 req.amount, 885 req.try_cancel_after, 886 req.include_invite, 887 req.extra_meta 888 ).await?; 889 yield serde_json::to_value(result)?; 890 } 891 "spend_notes_expert" => { 892 let req: SpendNotesExpertRequest = serde_json::from_value(request)?; 893 let result = self.spend_notes_with_selector( 894 &SelectNotesWithAtleastAmount, 895 req.min_amount, 896 req.try_cancel_after, 897 req.include_invite, 898 req.extra_meta 899 ).await?; 900 yield serde_json::to_value(result)?; 901 } 902 "validate_notes" => { 903 let req: ValidateNotesRequest = serde_json::from_value(request)?; 904 let result = self.validate_notes(&req.oob_notes)?; 905 yield serde_json::to_value(result)?; 906 } 907 "try_cancel_spend_notes" => { 908 let req: TryCancelSpendNotesRequest = serde_json::from_value(request)?; 909 let result = self.try_cancel_spend_notes(req.operation_id).await; 910 yield serde_json::to_value(result)?; 911 } 912 "subscribe_spend_notes" => { 913 let req: SubscribeSpendNotesRequest = serde_json::from_value(request)?; 914 let stream = self.subscribe_spend_notes(req.operation_id).await?; 915 for await state in stream.into_stream() { 916 yield serde_json::to_value(state)?; 917 } 918 } 919 "await_spend_oob_refund" => { 920 let req: AwaitSpendOobRefundRequest = serde_json::from_value(request)?; 921 let value = self.await_spend_oob_refund(req.operation_id).await; 922 yield serde_json::to_value(value)?; 923 } 924 _ => { 925 Err(anyhow::format_err!("Unknown method: {}", method))?; 926 unreachable!() 927 }, 928 } 929 }) 930 } 931 } 932 933 #[derive(Deserialize)] 934 struct ReissueExternalNotesRequest { 935 oob_notes: OOBNotes, 936 extra_meta: serde_json::Value, 937 } 938 939 #[derive(Deserialize)] 940 struct SubscribeReissueExternalNotesRequest { 941 operation_id: OperationId, 942 } 943 944 /// Caution: if no notes of the correct denomination are available the next 945 /// bigger note will be selected. You might want to use `spend_notes` instead. 946 #[derive(Deserialize)] 947 struct SpendNotesExpertRequest { 948 min_amount: Amount, 949 try_cancel_after: Duration, 950 include_invite: bool, 951 extra_meta: serde_json::Value, 952 } 953 954 #[derive(Deserialize)] 955 struct SpendNotesRequest { 956 amount: Amount, 957 try_cancel_after: Duration, 958 include_invite: bool, 959 extra_meta: serde_json::Value, 960 } 961 962 #[derive(Deserialize)] 963 struct ValidateNotesRequest { 964 oob_notes: OOBNotes, 965 } 966 967 #[derive(Deserialize)] 968 struct TryCancelSpendNotesRequest { 969 operation_id: OperationId, 970 } 971 972 #[derive(Deserialize)] 973 struct SubscribeSpendNotesRequest { 974 operation_id: OperationId, 975 } 976 977 #[derive(Deserialize)] 978 struct AwaitSpendOobRefundRequest { 979 operation_id: OperationId, 980 } 981 982 #[derive(thiserror::Error, Debug, Clone)] 983 pub enum ReissueExternalNotesError { 984 #[error("Federation ID does not match")] 985 WrongFederationId, 986 #[error("We already reissued these notes")] 987 AlreadyReissued, 988 } 989 990 impl MintClientModule { 991 async fn create_sufficient_input( 992 &self, 993 dbtx: &mut DatabaseTransaction<'_>, 994 min_amount: Amount, 995 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> { 996 if min_amount == Amount::ZERO { 997 return Ok(vec![]); 998 } 999 1000 let selected_notes = Self::select_notes( 1001 dbtx, 1002 &SelectNotesWithAtleastAmount, 1003 min_amount, 1004 self.cfg.fee_consensus.clone(), 1005 ) 1006 .await?; 1007 1008 for (amount, note) in selected_notes.iter_items() { 1009 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as sufficient input to fund a tx"); 1010 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await; 1011 } 1012 1013 let inputs = self.create_input_from_notes(selected_notes)?; 1014 1015 assert!(!inputs.is_empty()); 1016 1017 Ok(inputs) 1018 } 1019 1020 /// Returns the number of held e-cash notes per denomination 1021 #[deprecated( 1022 since = "0.5.0", 1023 note = "Use `get_note_counts_by_denomination` instead" 1024 )] 1025 pub async fn get_notes_tier_counts(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts { 1026 self.get_note_counts_by_denomination(dbtx).await 1027 } 1028 1029 /// Pick [`SpendableNote`]s by given counts, when available 1030 /// 1031 /// Return the notes picked, and counts of notes that were not available. 1032 pub async fn get_available_notes_by_tier_counts( 1033 &self, 1034 dbtx: &mut DatabaseTransaction<'_>, 1035 counts: TieredCounts, 1036 ) -> (TieredMulti<SpendableNoteUndecoded>, TieredCounts) { 1037 dbtx.find_by_prefix(&NoteKeyPrefix) 1038 .await 1039 .fold( 1040 (TieredMulti::<SpendableNoteUndecoded>::default(), counts), 1041 |(mut notes, mut counts), (key, note)| async move { 1042 let amount = key.amount; 1043 if 0 < counts.get(amount) { 1044 counts.dec(amount); 1045 notes.push(amount, note); 1046 } 1047 1048 (notes, counts) 1049 }, 1050 ) 1051 .await 1052 } 1053 1054 // TODO: put "notes per denomination" default into cfg 1055 /// Creates a mint output close to the given `amount`, issuing e-cash 1056 /// notes such that the client holds `notes_per_denomination` notes of each 1057 /// e-cash note denomination held. 1058 pub async fn create_output( 1059 &self, 1060 dbtx: &mut DatabaseTransaction<'_>, 1061 operation_id: OperationId, 1062 notes_per_denomination: u16, 1063 exact_amount: Amount, 1064 ) -> ClientOutputBundle<MintOutput, MintClientStateMachines> { 1065 if exact_amount == Amount::ZERO { 1066 return ClientOutputBundle::new(vec![], vec![]); 1067 } 1068 1069 let denominations = represent_amount( 1070 exact_amount, 1071 &self.get_note_counts_by_denomination(dbtx).await, 1072 &self.cfg.tbs_pks, 1073 notes_per_denomination, 1074 &self.cfg.fee_consensus, 1075 ); 1076 1077 let mut outputs = Vec::new(); 1078 let mut issuance_requests = Vec::new(); 1079 1080 for (amount, num) in denominations.iter() { 1081 for _ in 0..num { 1082 let (issuance_request, blind_nonce) = self.new_ecash_note(amount, dbtx).await; 1083 1084 debug!( 1085 %amount, 1086 "Generated issuance request" 1087 ); 1088 1089 outputs.push(ClientOutput { 1090 output: MintOutput::new_v0(amount, blind_nonce), 1091 amount, 1092 }); 1093 1094 issuance_requests.push((amount, issuance_request)); 1095 } 1096 } 1097 1098 let state_generator = Arc::new(move |out_point_range: OutPointRange| { 1099 assert_eq!(out_point_range.count(), issuance_requests.len()); 1100 vec![MintClientStateMachines::Output(MintOutputStateMachine { 1101 common: MintOutputCommon { 1102 operation_id, 1103 out_point_range, 1104 }, 1105 state: MintOutputStates::CreatedMulti(MintOutputStatesCreatedMulti { 1106 issuance_requests: out_point_range 1107 .into_iter() 1108 .map(|out_point| out_point.out_idx) 1109 .zip(issuance_requests.clone()) 1110 .collect(), 1111 }), 1112 })] 1113 }); 1114 1115 ClientOutputBundle::new( 1116 outputs, 1117 vec![ClientOutputSM { 1118 state_machines: state_generator, 1119 }], 1120 ) 1121 } 1122 1123 /// Returns the number of held e-cash notes per denomination 1124 pub async fn get_note_counts_by_denomination( 1125 &self, 1126 dbtx: &mut DatabaseTransaction<'_>, 1127 ) -> TieredCounts { 1128 dbtx.find_by_prefix(&NoteKeyPrefix) 1129 .await 1130 .fold( 1131 TieredCounts::default(), 1132 |mut acc, (key, _note)| async move { 1133 acc.inc(key.amount, 1); 1134 acc 1135 }, 1136 ) 1137 .await 1138 } 1139 1140 /// Returns the number of held e-cash notes per denomination 1141 #[deprecated( 1142 since = "0.5.0", 1143 note = "Use `get_note_counts_by_denomination` instead" 1144 )] 1145 pub async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> TieredCounts { 1146 self.get_note_counts_by_denomination(dbtx).await 1147 } 1148 1149 /// Wait for the e-cash notes to be retrieved. If this is not possible 1150 /// because another terminal state was reached an error describing the 1151 /// failure is returned. 1152 pub async fn await_output_finalized( 1153 &self, 1154 operation_id: OperationId, 1155 out_point: OutPoint, 1156 ) -> anyhow::Result<()> { 1157 let stream = self 1158 .notifier 1159 .subscribe(operation_id) 1160 .await 1161 .filter_map(|state| async { 1162 let MintClientStateMachines::Output(state) = state else { 1163 return None; 1164 }; 1165 1166 if state.common.txid() != out_point.txid 1167 || !state 1168 .common 1169 .out_point_range 1170 .out_idx_iter() 1171 .contains(&out_point.out_idx) 1172 { 1173 return None; 1174 } 1175 1176 match state.state { 1177 MintOutputStates::Succeeded(_) => Some(Ok(())), 1178 MintOutputStates::Aborted(_) => Some(Err(anyhow!("Transaction was rejected"))), 1179 MintOutputStates::Failed(failed) => Some(Err(anyhow!( 1180 "Failed to finalize transaction: {}", 1181 failed.error 1182 ))), 1183 MintOutputStates::Created(_) | MintOutputStates::CreatedMulti(_) => None, 1184 } 1185 }); 1186 pin_mut!(stream); 1187 1188 stream.next_or_pending().await 1189 } 1190 1191 /// Provisional implementation of note consolidation 1192 /// 1193 /// When a certain denomination crosses the threshold of notes allowed, 1194 /// spend some chunk of them as inputs. 1195 /// 1196 /// Return notes and the sume of their amount. 1197 pub async fn consolidate_notes( 1198 &self, 1199 dbtx: &mut DatabaseTransaction<'_>, 1200 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> { 1201 /// At how many notes of the same denomination should we try to 1202 /// consolidate 1203 const MAX_NOTES_PER_TIER_TRIGGER: usize = 8; 1204 /// Number of notes per tier to leave after threshold was crossed 1205 const MIN_NOTES_PER_TIER: usize = 4; 1206 /// Maximum number of notes to consolidate per one tx, 1207 /// to limit the size of a transaction produced. 1208 const MAX_NOTES_TO_CONSOLIDATE_IN_TX: usize = 20; 1209 // it's fine, it's just documentation 1210 #[allow(clippy::assertions_on_constants)] 1211 { 1212 assert!(MIN_NOTES_PER_TIER <= MAX_NOTES_PER_TIER_TRIGGER); 1213 } 1214 1215 let counts = self.get_note_counts_by_denomination(dbtx).await; 1216 1217 let should_consolidate = counts 1218 .iter() 1219 .any(|(_, count)| MAX_NOTES_PER_TIER_TRIGGER < count); 1220 1221 if !should_consolidate { 1222 return Ok(vec![]); 1223 } 1224 1225 let mut max_count = MAX_NOTES_TO_CONSOLIDATE_IN_TX; 1226 1227 let excessive_counts: TieredCounts = counts 1228 .iter() 1229 .map(|(amount, count)| { 1230 let take = (count.saturating_sub(MIN_NOTES_PER_TIER)).min(max_count); 1231 1232 max_count -= take; 1233 (amount, take) 1234 }) 1235 .collect(); 1236 1237 let (selected_notes, unavailable) = self 1238 .get_available_notes_by_tier_counts(dbtx, excessive_counts) 1239 .await; 1240 1241 debug_assert!( 1242 unavailable.is_empty(), 1243 "Can't have unavailable notes on a subset of all notes: {unavailable:?}" 1244 ); 1245 1246 if !selected_notes.is_empty() { 1247 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"); 1248 } 1249 1250 let mut selected_notes_decoded = vec![]; 1251 for (amount, note) in selected_notes.iter_items() { 1252 let spendable_note_decoded = note.decode()?; 1253 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Consolidating note"); 1254 Self::delete_spendable_note(&self.client_ctx, dbtx, amount, &spendable_note_decoded) 1255 .await; 1256 selected_notes_decoded.push((amount, spendable_note_decoded)); 1257 } 1258 1259 self.create_input_from_notes(selected_notes_decoded.into_iter().collect()) 1260 } 1261 1262 /// Create a mint input from external, potentially untrusted notes 1263 #[allow(clippy::type_complexity)] 1264 pub fn create_input_from_notes( 1265 &self, 1266 notes: TieredMulti<SpendableNote>, 1267 ) -> anyhow::Result<Vec<(ClientInput<MintInput>, SpendableNote)>> { 1268 let mut inputs_and_notes = Vec::new(); 1269 1270 for (amount, spendable_note) in notes.into_iter_items() { 1271 let key = self 1272 .cfg 1273 .tbs_pks 1274 .get(amount) 1275 .ok_or(anyhow!("Invalid amount tier: {amount}"))?; 1276 1277 let note = spendable_note.note(); 1278 1279 if !note.verify(*key) { 1280 bail!("Invalid note"); 1281 } 1282 1283 inputs_and_notes.push(( 1284 ClientInput { 1285 input: MintInput::new_v0(amount, note), 1286 keys: vec![spendable_note.spend_key], 1287 amount, 1288 }, 1289 spendable_note, 1290 )); 1291 } 1292 1293 Ok(inputs_and_notes) 1294 } 1295 1296 async fn spend_notes_oob( 1297 &self, 1298 dbtx: &mut DatabaseTransaction<'_>, 1299 notes_selector: &impl NotesSelector, 1300 amount: Amount, 1301 try_cancel_after: Duration, 1302 ) -> anyhow::Result<( 1303 OperationId, 1304 Vec<MintClientStateMachines>, 1305 TieredMulti<SpendableNote>, 1306 )> { 1307 ensure!( 1308 amount > Amount::ZERO, 1309 "zero-amount out-of-band spends are not supported" 1310 ); 1311 1312 let selected_notes = 1313 Self::select_notes(dbtx, notes_selector, amount, FeeConsensus::zero()).await?; 1314 1315 let operation_id = spendable_notes_to_operation_id(&selected_notes); 1316 1317 for (amount, note) in selected_notes.iter_items() { 1318 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Spending note as oob"); 1319 MintClientModule::delete_spendable_note(&self.client_ctx, dbtx, amount, note).await; 1320 } 1321 1322 let state_machines = vec![MintClientStateMachines::OOB(MintOOBStateMachine { 1323 operation_id, 1324 state: MintOOBStates::CreatedMulti(MintOOBStatesCreatedMulti { 1325 spendable_notes: selected_notes.clone().into_iter_items().collect(), 1326 timeout: fedimint_core::time::now() + try_cancel_after, 1327 }), 1328 })]; 1329 1330 Ok((operation_id, state_machines, selected_notes)) 1331 } 1332 1333 pub async fn await_spend_oob_refund(&self, operation_id: OperationId) -> SpendOOBRefund { 1334 Box::pin( 1335 self.notifier 1336 .subscribe(operation_id) 1337 .await 1338 .filter_map(|state| async { 1339 let MintClientStateMachines::OOB(state) = state else { 1340 return None; 1341 }; 1342 1343 match state.state { 1344 MintOOBStates::TimeoutRefund(refund) => Some(SpendOOBRefund { 1345 user_triggered: false, 1346 transaction_ids: vec![refund.refund_txid], 1347 }), 1348 MintOOBStates::UserRefund(refund) => Some(SpendOOBRefund { 1349 user_triggered: true, 1350 transaction_ids: vec![refund.refund_txid], 1351 }), 1352 MintOOBStates::UserRefundMulti(refund) => Some(SpendOOBRefund { 1353 user_triggered: true, 1354 transaction_ids: vec![refund.refund_txid], 1355 }), 1356 MintOOBStates::Created(_) | MintOOBStates::CreatedMulti(_) => None, 1357 } 1358 }), 1359 ) 1360 .next_or_pending() 1361 .await 1362 } 1363 1364 /// Select notes with `requested_amount` using `notes_selector`. 1365 async fn select_notes( 1366 dbtx: &mut DatabaseTransaction<'_>, 1367 notes_selector: &impl NotesSelector, 1368 requested_amount: Amount, 1369 fee_consensus: FeeConsensus, 1370 ) -> anyhow::Result<TieredMulti<SpendableNote>> { 1371 let note_stream = dbtx 1372 .find_by_prefix_sorted_descending(&NoteKeyPrefix) 1373 .await 1374 .map(|(key, note)| (key.amount, note)); 1375 1376 notes_selector 1377 .select_notes(note_stream, requested_amount, fee_consensus) 1378 .await? 1379 .into_iter_items() 1380 .map(|(amt, snote)| Ok((amt, snote.decode()?))) 1381 .collect::<anyhow::Result<TieredMulti<_>>>() 1382 } 1383 1384 async fn get_all_spendable_notes( 1385 dbtx: &mut DatabaseTransaction<'_>, 1386 ) -> TieredMulti<SpendableNoteUndecoded> { 1387 (dbtx 1388 .find_by_prefix(&NoteKeyPrefix) 1389 .await 1390 .map(|(key, note)| (key.amount, note)) 1391 .collect::<Vec<_>>() 1392 .await) 1393 .into_iter() 1394 .collect() 1395 } 1396 1397 async fn get_next_note_index( 1398 &self, 1399 dbtx: &mut DatabaseTransaction<'_>, 1400 amount: Amount, 1401 ) -> NoteIndex { 1402 NoteIndex( 1403 dbtx.get_value(&NextECashNoteIndexKey(amount)) 1404 .await 1405 .unwrap_or(0), 1406 ) 1407 } 1408 1409 /// Derive the note `DerivableSecret` from the Mint's `secret` the `amount` 1410 /// tier and `note_idx` 1411 /// 1412 /// Static to help re-use in other places, that don't have a whole [`Self`] 1413 /// available 1414 /// 1415 /// # E-Cash Note Creation 1416 /// 1417 /// When creating an e-cash note, the `MintClientModule` first derives the 1418 /// blinding and spend keys from the `DerivableSecret`. It then creates a 1419 /// `NoteIssuanceRequest` containing the blinded spend key and sends it to 1420 /// the mint server. The mint server signs the blinded spend key and 1421 /// returns it to the client. The client can then unblind the signed 1422 /// spend key to obtain the e-cash note, which can be spent using the 1423 /// spend key. 1424 pub fn new_note_secret_static( 1425 secret: &DerivableSecret, 1426 amount: Amount, 1427 note_idx: NoteIndex, 1428 ) -> DerivableSecret { 1429 assert_eq!(secret.level(), 2); 1430 debug!(?secret, %amount, %note_idx, "Deriving new mint note"); 1431 secret 1432 .child_key(MINT_E_CASH_TYPE_CHILD_ID) // TODO: cache 1433 .child_key(ChildId(note_idx.as_u64())) 1434 .child_key(ChildId(amount.msats)) 1435 } 1436 1437 /// We always keep track of an incrementing index in the database and use 1438 /// it as part of the derivation path for the note secret. This ensures that 1439 /// we never reuse the same note secret twice. 1440 async fn new_note_secret( 1441 &self, 1442 amount: Amount, 1443 dbtx: &mut DatabaseTransaction<'_>, 1444 ) -> DerivableSecret { 1445 let new_idx = self.get_next_note_index(dbtx, amount).await; 1446 dbtx.insert_entry(&NextECashNoteIndexKey(amount), &new_idx.next().as_u64()) 1447 .await; 1448 Self::new_note_secret_static(&self.secret, amount, new_idx) 1449 } 1450 1451 pub async fn new_ecash_note( 1452 &self, 1453 amount: Amount, 1454 dbtx: &mut DatabaseTransaction<'_>, 1455 ) -> (NoteIssuanceRequest, BlindNonce) { 1456 let secret = self.new_note_secret(amount, dbtx).await; 1457 NoteIssuanceRequest::new(&self.secp, &secret) 1458 } 1459 1460 /// Try to reissue e-cash notes received from a third party to receive them 1461 /// in our wallet. The progress and outcome can be observed using 1462 /// [`MintClientModule::subscribe_reissue_external_notes`]. 1463 /// Can return error of type [`ReissueExternalNotesError`] 1464 pub async fn reissue_external_notes<M: Serialize + Send>( 1465 &self, 1466 oob_notes: OOBNotes, 1467 extra_meta: M, 1468 ) -> anyhow::Result<OperationId> { 1469 let notes = oob_notes.notes().clone(); 1470 let federation_id_prefix = oob_notes.federation_id_prefix(); 1471 1472 ensure!( 1473 notes.total_amount() > Amount::ZERO, 1474 "Reissuing zero-amount e-cash isn't supported" 1475 ); 1476 1477 if federation_id_prefix != self.federation_id.to_prefix() { 1478 bail!(ReissueExternalNotesError::WrongFederationId); 1479 } 1480 1481 let operation_id = OperationId( 1482 notes 1483 .consensus_hash::<sha256t::Hash<OOBReissueTag>>() 1484 .to_byte_array(), 1485 ); 1486 1487 let amount = notes.total_amount(); 1488 let mint_inputs = self.create_input_from_notes(notes)?; 1489 1490 let tx = TransactionBuilder::new().with_inputs( 1491 self.client_ctx 1492 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)), 1493 ); 1494 1495 let extra_meta = serde_json::to_value(extra_meta) 1496 .expect("MintClientModule::reissue_external_notes extra_meta is serializable"); 1497 let operation_meta_gen = |change_range: OutPointRange| MintOperationMeta { 1498 variant: MintOperationMetaVariant::Reissuance { 1499 legacy_out_point: None, 1500 txid: Some(change_range.txid()), 1501 out_point_indices: change_range 1502 .into_iter() 1503 .map(|out_point| out_point.out_idx) 1504 .collect(), 1505 }, 1506 amount, 1507 extra_meta: extra_meta.clone(), 1508 }; 1509 1510 self.client_ctx 1511 .finalize_and_submit_transaction( 1512 operation_id, 1513 MintCommonInit::KIND.as_str(), 1514 operation_meta_gen, 1515 tx, 1516 ) 1517 .await 1518 .context(ReissueExternalNotesError::AlreadyReissued)?; 1519 let mut dbtx = self.client_ctx.module_db().begin_transaction().await; 1520 self.client_ctx 1521 .log_event(&mut dbtx, OOBNotesReissued { amount }) 1522 .await; 1523 dbtx.commit_tx().await; 1524 1525 Ok(operation_id) 1526 } 1527 1528 /// Subscribe to updates on the progress of a reissue operation started with 1529 /// [`MintClientModule::reissue_external_notes`]. 1530 pub async fn subscribe_reissue_external_notes( 1531 &self, 1532 operation_id: OperationId, 1533 ) -> anyhow::Result<UpdateStreamOrOutcome<ReissueExternalNotesState>> { 1534 let operation = self.mint_operation(operation_id).await?; 1535 let (txid, out_points) = match operation.meta::<MintOperationMeta>().variant { 1536 MintOperationMetaVariant::Reissuance { 1537 legacy_out_point, 1538 txid, 1539 out_point_indices, 1540 } => { 1541 // Either txid or legacy_out_point will be present, so we should always 1542 // have a source for the txid 1543 let txid = txid 1544 .or(legacy_out_point.map(|out_point| out_point.txid)) 1545 .context("Empty reissuance not permitted, this should never happen")?; 1546 1547 let out_points = out_point_indices 1548 .into_iter() 1549 .map(|out_idx| OutPoint { txid, out_idx }) 1550 .chain(legacy_out_point) 1551 .collect::<Vec<_>>(); 1552 1553 (txid, out_points) 1554 } 1555 MintOperationMetaVariant::SpendOOB { .. } => bail!("Operation is not a reissuance"), 1556 }; 1557 1558 let client_ctx = self.client_ctx.clone(); 1559 1560 Ok(self.client_ctx.outcome_or_updates(&operation, operation_id, || { 1561 stream! { 1562 yield ReissueExternalNotesState::Created; 1563 1564 match client_ctx 1565 .transaction_updates(operation_id) 1566 .await 1567 .await_tx_accepted(txid) 1568 .await 1569 { 1570 Ok(()) => { 1571 yield ReissueExternalNotesState::Issuing; 1572 } 1573 Err(e) => { 1574 yield ReissueExternalNotesState::Failed(format!("Transaction not accepted {e:?}")); 1575 return; 1576 } 1577 } 1578 1579 for out_point in out_points { 1580 if let Err(e) = client_ctx.self_ref().await_output_finalized(operation_id, out_point).await { 1581 yield ReissueExternalNotesState::Failed(e.to_string()); 1582 return; 1583 } 1584 } 1585 yield ReissueExternalNotesState::Done; 1586 }} 1587 )) 1588 } 1589 1590 /// Fetches and removes notes of *at least* amount `min_amount` from the 1591 /// wallet to be sent to the recipient out of band. These spends can be 1592 /// canceled by calling [`MintClientModule::try_cancel_spend_notes`] as long 1593 /// as the recipient hasn't reissued the e-cash notes themselves yet. 1594 /// 1595 /// The client will also automatically attempt to cancel the operation after 1596 /// `try_cancel_after` time has passed. This is a safety mechanism to avoid 1597 /// users forgetting about failed out-of-band transactions. The timeout 1598 /// should be chosen such that the recipient (who is potentially offline at 1599 /// the time of receiving the e-cash notes) had a reasonable timeframe to 1600 /// come online and reissue the notes themselves. 1601 #[deprecated( 1602 since = "0.5.0", 1603 note = "Use `spend_notes_with_selector` instead, with `SelectNotesWithAtleastAmount` to maintain the same behavior" 1604 )] 1605 pub async fn spend_notes<M: Serialize + Send>( 1606 &self, 1607 min_amount: Amount, 1608 try_cancel_after: Duration, 1609 include_invite: bool, 1610 extra_meta: M, 1611 ) -> anyhow::Result<(OperationId, OOBNotes)> { 1612 self.spend_notes_with_selector( 1613 &SelectNotesWithAtleastAmount, 1614 min_amount, 1615 try_cancel_after, 1616 include_invite, 1617 extra_meta, 1618 ) 1619 .await 1620 } 1621 1622 /// Fetches and removes notes from the wallet to be sent to the recipient 1623 /// out of band. The not selection algorithm is determined by 1624 /// `note_selector`. See the [`NotesSelector`] trait for available 1625 /// implementations. 1626 /// 1627 /// These spends can be canceled by calling 1628 /// [`MintClientModule::try_cancel_spend_notes`] as long 1629 /// as the recipient hasn't reissued the e-cash notes themselves yet. 1630 /// 1631 /// The client will also automatically attempt to cancel the operation after 1632 /// `try_cancel_after` time has passed. This is a safety mechanism to avoid 1633 /// users forgetting about failed out-of-band transactions. The timeout 1634 /// should be chosen such that the recipient (who is potentially offline at 1635 /// the time of receiving the e-cash notes) had a reasonable timeframe to 1636 /// come online and reissue the notes themselves. 1637 pub async fn spend_notes_with_selector<M: Serialize + Send>( 1638 &self, 1639 notes_selector: &impl NotesSelector, 1640 requested_amount: Amount, 1641 try_cancel_after: Duration, 1642 include_invite: bool, 1643 extra_meta: M, 1644 ) -> anyhow::Result<(OperationId, OOBNotes)> { 1645 let federation_id_prefix = self.federation_id.to_prefix(); 1646 let extra_meta = serde_json::to_value(extra_meta) 1647 .expect("MintClientModule::spend_notes extra_meta is serializable"); 1648 1649 self.client_ctx 1650 .module_db() 1651 .autocommit( 1652 |dbtx, _| { 1653 let extra_meta = extra_meta.clone(); 1654 Box::pin(async { 1655 let (operation_id, states, notes) = self 1656 .spend_notes_oob( 1657 dbtx, 1658 notes_selector, 1659 requested_amount, 1660 try_cancel_after, 1661 ) 1662 .await?; 1663 1664 let oob_notes = if include_invite { 1665 OOBNotes::new_with_invite( 1666 notes, 1667 &self.client_ctx.get_invite_code().await, 1668 ) 1669 } else { 1670 OOBNotes::new(federation_id_prefix, notes) 1671 }; 1672 1673 self.client_ctx 1674 .add_state_machines_dbtx( 1675 dbtx, 1676 self.client_ctx.map_dyn(states).collect(), 1677 ) 1678 .await?; 1679 self.client_ctx 1680 .add_operation_log_entry_dbtx( 1681 dbtx, 1682 operation_id, 1683 MintCommonInit::KIND.as_str(), 1684 MintOperationMeta { 1685 variant: MintOperationMetaVariant::SpendOOB { 1686 requested_amount, 1687 oob_notes: oob_notes.clone(), 1688 }, 1689 amount: oob_notes.total_amount(), 1690 extra_meta, 1691 }, 1692 ) 1693 .await; 1694 self.client_ctx 1695 .log_event( 1696 dbtx, 1697 OOBNotesSpent { 1698 requested_amount, 1699 spent_amount: oob_notes.total_amount(), 1700 timeout: try_cancel_after, 1701 include_invite, 1702 }, 1703 ) 1704 .await; 1705 1706 Ok((operation_id, oob_notes)) 1707 }) 1708 }, 1709 Some(100), 1710 ) 1711 .await 1712 .map_err(|e| match e { 1713 AutocommitError::ClosureError { error, .. } => error, 1714 AutocommitError::CommitFailed { last_error, .. } => { 1715 anyhow!("Commit to DB failed: {last_error}") 1716 } 1717 }) 1718 } 1719 1720 /// Validate the given notes and return the total amount of the notes. 1721 /// Validation checks that: 1722 /// - the federation ID is correct 1723 /// - the note has a valid signature 1724 /// - the spend key is correct. 1725 pub fn validate_notes(&self, oob_notes: &OOBNotes) -> anyhow::Result<Amount> { 1726 let federation_id_prefix = oob_notes.federation_id_prefix(); 1727 let notes = oob_notes.notes().clone(); 1728 1729 if federation_id_prefix != self.federation_id.to_prefix() { 1730 bail!("Federation ID does not match"); 1731 } 1732 1733 let tbs_pks = &self.cfg.tbs_pks; 1734 1735 for (idx, (amt, snote)) in notes.iter_items().enumerate() { 1736 let key = tbs_pks 1737 .get(amt) 1738 .ok_or_else(|| anyhow!("Note {idx} uses an invalid amount tier {amt}"))?; 1739 1740 let note = snote.note(); 1741 if !note.verify(*key) { 1742 bail!("Note {idx} has an invalid federation signature"); 1743 } 1744 1745 let expected_nonce = Nonce(snote.spend_key.public_key()); 1746 if note.nonce != expected_nonce { 1747 bail!("Note {idx} cannot be spent using the supplied spend key"); 1748 } 1749 } 1750 1751 Ok(notes.total_amount()) 1752 } 1753 1754 /// Try to cancel a spend operation started with 1755 /// [`MintClientModule::spend_notes_with_selector`]. If the e-cash notes 1756 /// have already been spent this operation will fail which can be 1757 /// observed using [`MintClientModule::subscribe_spend_notes`]. 1758 pub async fn try_cancel_spend_notes(&self, operation_id: OperationId) { 1759 let mut dbtx = self.client_ctx.module_db().begin_transaction().await; 1760 dbtx.insert_entry(&CancelledOOBSpendKey(operation_id), &()) 1761 .await; 1762 if let Err(e) = dbtx.commit_tx_result().await { 1763 warn!("We tried to cancel the same OOB spend multiple times concurrently: {e}"); 1764 } 1765 } 1766 1767 /// Subscribe to updates on the progress of a raw e-cash spend operation 1768 /// started with [`MintClientModule::spend_notes_with_selector`]. 1769 pub async fn subscribe_spend_notes( 1770 &self, 1771 operation_id: OperationId, 1772 ) -> anyhow::Result<UpdateStreamOrOutcome<SpendOOBState>> { 1773 let operation = self.mint_operation(operation_id).await?; 1774 if !matches!( 1775 operation.meta::<MintOperationMeta>().variant, 1776 MintOperationMetaVariant::SpendOOB { .. } 1777 ) { 1778 bail!("Operation is not a out-of-band spend"); 1779 }; 1780 1781 let client_ctx = self.client_ctx.clone(); 1782 1783 Ok(self 1784 .client_ctx 1785 .outcome_or_updates(&operation, operation_id, || { 1786 stream! { 1787 yield SpendOOBState::Created; 1788 1789 let self_ref = client_ctx.self_ref(); 1790 1791 let refund = self_ref 1792 .await_spend_oob_refund(operation_id) 1793 .await; 1794 1795 if refund.user_triggered { 1796 yield SpendOOBState::UserCanceledProcessing; 1797 } 1798 1799 let mut success = true; 1800 1801 for txid in refund.transaction_ids { 1802 debug!( 1803 target: LOG_CLIENT_MODULE_MINT, 1804 %txid, 1805 operation_id=%operation_id.fmt_short(), 1806 "Waiting for oob refund txid" 1807 ); 1808 if client_ctx 1809 .transaction_updates(operation_id) 1810 .await 1811 .await_tx_accepted(txid) 1812 .await.is_err() { 1813 success = false; 1814 } 1815 } 1816 1817 debug!( 1818 target: LOG_CLIENT_MODULE_MINT, 1819 operation_id=%operation_id.fmt_short(), 1820 %success, 1821 "Done waiting for all refund oob txids" 1822 ); 1823 1824 match (refund.user_triggered, success) { 1825 (true, true) => { 1826 yield SpendOOBState::UserCanceledSuccess; 1827 }, 1828 (true, false) => { 1829 yield SpendOOBState::UserCanceledFailure; 1830 }, 1831 (false, true) => { 1832 yield SpendOOBState::Refunded; 1833 }, 1834 (false, false) => { 1835 yield SpendOOBState::Success; 1836 } 1837 } 1838 } 1839 })) 1840 } 1841 1842 async fn mint_operation(&self, operation_id: OperationId) -> anyhow::Result<OperationLogEntry> { 1843 let operation = self.client_ctx.get_operation(operation_id).await?; 1844 1845 if operation.operation_module_kind() != MintCommonInit::KIND.as_str() { 1846 bail!("Operation is not a mint operation"); 1847 } 1848 1849 Ok(operation) 1850 } 1851 1852 async fn delete_spendable_note( 1853 client_ctx: &ClientContext<MintClientModule>, 1854 dbtx: &mut DatabaseTransaction<'_>, 1855 amount: Amount, 1856 note: &SpendableNote, 1857 ) { 1858 client_ctx 1859 .log_event( 1860 dbtx, 1861 NoteSpent { 1862 nonce: note.nonce(), 1863 }, 1864 ) 1865 .await; 1866 dbtx.remove_entry(&NoteKey { 1867 amount, 1868 nonce: note.nonce(), 1869 }) 1870 .await 1871 .expect("Must deleted existing spendable note"); 1872 } 1873 1874 pub async fn advance_note_idx(&self, amount: Amount) -> anyhow::Result<DerivableSecret> { 1875 let db = self.client_ctx.module_db().clone(); 1876 1877 Ok(db 1878 .autocommit( 1879 |dbtx, _| { 1880 Box::pin(async { 1881 Ok::<DerivableSecret, anyhow::Error>( 1882 self.new_note_secret(amount, dbtx).await, 1883 ) 1884 }) 1885 }, 1886 None, 1887 ) 1888 .await?) 1889 } 1890 1891 /// Returns secrets for the note indices that were reused by previous 1892 /// clients with same client secret. 1893 pub async fn reused_note_secrets(&self) -> Vec<(Amount, NoteIssuanceRequest, BlindNonce)> { 1894 self.client_ctx 1895 .module_db() 1896 .begin_transaction_nc() 1897 .await 1898 .get_value(&ReusedNoteIndices) 1899 .await 1900 .unwrap_or_default() 1901 .into_iter() 1902 .map(|(amount, note_idx)| { 1903 let secret = Self::new_note_secret_static(&self.secret, amount, note_idx); 1904 let (request, blind_nonce) = 1905 NoteIssuanceRequest::new(fedimint_core::secp256k1::SECP256K1, &secret); 1906 (amount, request, blind_nonce) 1907 }) 1908 .collect() 1909 } 1910 } 1911 1912 pub fn spendable_notes_to_operation_id( 1913 spendable_selected_notes: &TieredMulti<SpendableNote>, 1914 ) -> OperationId { 1915 OperationId( 1916 spendable_selected_notes 1917 .consensus_hash::<sha256t::Hash<OOBSpendTag>>() 1918 .to_byte_array(), 1919 ) 1920 } 1921 1922 #[derive(Debug, Serialize, Deserialize, Clone)] 1923 pub struct SpendOOBRefund { 1924 pub user_triggered: bool, 1925 pub transaction_ids: Vec<TransactionId>, 1926 } 1927 1928 /// Defines a strategy for selecting e-cash notes given a specific target amount 1929 /// and fee per note transaction input. 1930 #[apply(async_trait_maybe_send!)] 1931 pub trait NotesSelector<Note = SpendableNoteUndecoded>: Send + Sync { 1932 /// Select notes from stream for requested_amount. 1933 /// The stream must produce items in non- decreasing order of amount. 1934 async fn select_notes( 1935 &self, 1936 // FIXME: async trait doesn't like maybe_add_send 1937 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send, 1938 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>, 1939 requested_amount: Amount, 1940 fee_consensus: FeeConsensus, 1941 ) -> anyhow::Result<TieredMulti<Note>>; 1942 } 1943 1944 /// Select notes with total amount of *at least* `request_amount`. If more than 1945 /// requested amount of notes are returned it was because exact change couldn't 1946 /// be made, and the next smallest amount will be returned. 1947 /// 1948 /// The caller can request change from the federation. 1949 pub struct SelectNotesWithAtleastAmount; 1950 1951 #[apply(async_trait_maybe_send!)] 1952 impl<Note: Send> NotesSelector<Note> for SelectNotesWithAtleastAmount { 1953 async fn select_notes( 1954 &self, 1955 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send, 1956 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>, 1957 requested_amount: Amount, 1958 fee_consensus: FeeConsensus, 1959 ) -> anyhow::Result<TieredMulti<Note>> { 1960 Ok(select_notes_from_stream(stream, requested_amount, fee_consensus).await?) 1961 } 1962 } 1963 1964 /// Select notes with total amount of *exactly* `request_amount`. If the amount 1965 /// cannot be represented with the available denominations an error is returned, 1966 /// this **does not** mean that the balance is too low. 1967 pub struct SelectNotesWithExactAmount; 1968 1969 #[apply(async_trait_maybe_send!)] 1970 impl<Note: Send> NotesSelector<Note> for SelectNotesWithExactAmount { 1971 async fn select_notes( 1972 &self, 1973 #[cfg(not(target_family = "wasm"))] stream: impl futures::Stream<Item = (Amount, Note)> + Send, 1974 #[cfg(target_family = "wasm")] stream: impl futures::Stream<Item = (Amount, Note)>, 1975 requested_amount: Amount, 1976 fee_consensus: FeeConsensus, 1977 ) -> anyhow::Result<TieredMulti<Note>> { 1978 let notes = select_notes_from_stream(stream, requested_amount, fee_consensus).await?; 1979 1980 if notes.total_amount() != requested_amount { 1981 bail!( 1982 "Could not select notes with exact amount. Requested amount: {}. Selected amount: {}", 1983 requested_amount, 1984 notes.total_amount() 1985 ); 1986 } 1987 1988 Ok(notes) 1989 } 1990 } 1991 1992 // We are using a greedy algorithm to select notes. We start with the largest 1993 // then proceed to the lowest tiers/denominations. 1994 // But there is a catch: we don't know if there are enough notes in the lowest 1995 // tiers, so we need to save a big note in case the sum of the following 1996 // small notes are not enough. 1997 async fn select_notes_from_stream<Note>( 1998 stream: impl futures::Stream<Item = (Amount, Note)>, 1999 requested_amount: Amount, 2000 fee_consensus: FeeConsensus, 2001 ) -> Result<TieredMulti<Note>, InsufficientBalanceError> { 2002 if requested_amount == Amount::ZERO { 2003 return Ok(TieredMulti::default()); 2004 } 2005 let mut stream = Box::pin(stream); 2006 let mut selected = vec![]; 2007 // This is the big note we save in case the sum of the following small notes are 2008 // not sufficient to cover the pending amount 2009 // The tuple is (amount, note, checkpoint), where checkpoint is the index where 2010 // the note should be inserted on the selected vector if it is needed 2011 let mut last_big_note_checkpoint: Option<(Amount, Note, usize)> = None; 2012 let mut pending_amount = requested_amount; 2013 let mut previous_amount: Option<Amount> = None; // used to assert descending order 2014 loop { 2015 if let Some((note_amount, note)) = stream.next().await { 2016 assert!( 2017 previous_amount.map_or(true, |previous| previous >= note_amount), 2018 "notes are not sorted in descending order" 2019 ); 2020 previous_amount = Some(note_amount); 2021 2022 if note_amount <= fee_consensus.fee(note_amount) { 2023 continue; 2024 } 2025 2026 match note_amount.cmp(&(pending_amount + fee_consensus.fee(note_amount))) { 2027 Ordering::Less => { 2028 // keep adding notes until we have enough 2029 pending_amount += fee_consensus.fee(note_amount); 2030 pending_amount -= note_amount; 2031 selected.push((note_amount, note)); 2032 } 2033 Ordering::Greater => { 2034 // probably we don't need this big note, but we'll keep it in case the 2035 // following small notes don't add up to the 2036 // requested amount 2037 last_big_note_checkpoint = Some((note_amount, note, selected.len())); 2038 } 2039 Ordering::Equal => { 2040 // exactly enough notes, return 2041 selected.push((note_amount, note)); 2042 2043 let notes: TieredMulti<Note> = selected.into_iter().collect(); 2044 2045 assert!( 2046 notes.total_amount().msats 2047 >= requested_amount.msats 2048 + notes 2049 .iter() 2050 .map(|note| fee_consensus.fee(note.0)) 2051 .sum::<Amount>() 2052 .msats 2053 ); 2054 2055 return Ok(notes); 2056 } 2057 } 2058 } else { 2059 assert!(pending_amount > Amount::ZERO); 2060 if let Some((big_note_amount, big_note, checkpoint)) = last_big_note_checkpoint { 2061 // the sum of the small notes don't add up to the pending amount, remove 2062 // them 2063 selected.truncate(checkpoint); 2064 // and use the big note to cover it 2065 selected.push((big_note_amount, big_note)); 2066 2067 let notes: TieredMulti<Note> = selected.into_iter().collect(); 2068 2069 assert!( 2070 notes.total_amount().msats 2071 >= requested_amount.msats 2072 + notes 2073 .iter() 2074 .map(|note| fee_consensus.fee(note.0)) 2075 .sum::<Amount>() 2076 .msats 2077 ); 2078 2079 // so now we have enough to cover the requested amount, return 2080 return Ok(notes); 2081 } 2082 2083 let total_amount = requested_amount.saturating_sub(pending_amount); 2084 // not enough notes, return 2085 return Err(InsufficientBalanceError { 2086 requested_amount, 2087 total_amount, 2088 }); 2089 } 2090 } 2091 } 2092 2093 #[derive(Debug, Clone, Error)] 2094 pub struct InsufficientBalanceError { 2095 pub requested_amount: Amount, 2096 pub total_amount: Amount, 2097 } 2098 2099 impl std::fmt::Display for InsufficientBalanceError { 2100 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 2101 write!( 2102 f, 2103 "Insufficient balance: requested {} but only {} available", 2104 self.requested_amount, self.total_amount 2105 ) 2106 } 2107 } 2108 2109 /// Old and no longer used, will be deleted in the future 2110 #[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)] 2111 enum MintRestoreStates { 2112 #[encodable_default] 2113 Default { variant: u64, bytes: Vec<u8> }, 2114 } 2115 2116 /// Old and no longer used, will be deleted in the future 2117 #[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)] 2118 pub struct MintRestoreStateMachine { 2119 operation_id: OperationId, 2120 state: MintRestoreStates, 2121 } 2122 2123 #[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)] 2124 pub enum MintClientStateMachines { 2125 Output(MintOutputStateMachine), 2126 Input(MintInputStateMachine), 2127 OOB(MintOOBStateMachine), 2128 // Removed in https://github.com/fedimint/fedimint/pull/4035 , now ignored 2129 Restore(MintRestoreStateMachine), 2130 } 2131 2132 impl IntoDynInstance for MintClientStateMachines { 2133 type DynType = DynState; 2134 2135 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType { 2136 DynState::from_typed(instance_id, self) 2137 } 2138 } 2139 2140 impl State for MintClientStateMachines { 2141 type ModuleContext = MintClientContext; 2142 2143 fn transitions( 2144 &self, 2145 context: &Self::ModuleContext, 2146 global_context: &DynGlobalClientContext, 2147 ) -> Vec<StateTransition<Self>> { 2148 match self { 2149 MintClientStateMachines::Output(issuance_state) => { 2150 sm_enum_variant_translation!( 2151 issuance_state.transitions(context, global_context), 2152 MintClientStateMachines::Output 2153 ) 2154 } 2155 MintClientStateMachines::Input(redemption_state) => { 2156 sm_enum_variant_translation!( 2157 redemption_state.transitions(context, global_context), 2158 MintClientStateMachines::Input 2159 ) 2160 } 2161 MintClientStateMachines::OOB(oob_state) => { 2162 sm_enum_variant_translation!( 2163 oob_state.transitions(context, global_context), 2164 MintClientStateMachines::OOB 2165 ) 2166 } 2167 MintClientStateMachines::Restore(_) => { 2168 sm_enum_variant_translation!(vec![], MintClientStateMachines::Restore) 2169 } 2170 } 2171 } 2172 2173 fn operation_id(&self) -> OperationId { 2174 match self { 2175 MintClientStateMachines::Output(issuance_state) => issuance_state.operation_id(), 2176 MintClientStateMachines::Input(redemption_state) => redemption_state.operation_id(), 2177 MintClientStateMachines::OOB(oob_state) => oob_state.operation_id(), 2178 MintClientStateMachines::Restore(r) => r.operation_id, 2179 } 2180 } 2181 } 2182 2183 /// A [`Note`] with associated secret key that allows to proof ownership (spend 2184 /// it) 2185 #[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Encodable, Decodable)] 2186 pub struct SpendableNote { 2187 pub signature: tbs::Signature, 2188 pub spend_key: Keypair, 2189 } 2190 2191 impl fmt::Debug for SpendableNote { 2192 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 2193 f.debug_struct("SpendableNote") 2194 .field("nonce", &self.nonce()) 2195 .field("signature", &self.signature) 2196 .field("spend_key", &self.spend_key) 2197 .finish() 2198 } 2199 } 2200 impl fmt::Display for SpendableNote { 2201 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 2202 self.nonce().fmt(f) 2203 } 2204 } 2205 2206 impl SpendableNote { 2207 pub fn nonce(&self) -> Nonce { 2208 Nonce(self.spend_key.public_key()) 2209 } 2210 2211 fn note(&self) -> Note { 2212 Note { 2213 nonce: self.nonce(), 2214 signature: self.signature, 2215 } 2216 } 2217 2218 pub fn to_undecoded(&self) -> SpendableNoteUndecoded { 2219 SpendableNoteUndecoded { 2220 signature: self 2221 .signature 2222 .consensus_encode_to_vec() 2223 .try_into() 2224 .expect("Encoded size always correct"), 2225 spend_key: self.spend_key, 2226 } 2227 } 2228 } 2229 2230 /// A version of [`SpendableNote`] that didn't decode the `signature` yet 2231 /// 2232 /// **Note**: signature decoding from raw bytes is faliable, as not all bytes 2233 /// are valid signatures. Therefore this type must not be used for external 2234 /// data, and should be limited to optimizing reading from internal database. 2235 /// 2236 /// The signature bytes will be validated in [`Self::decode`]. 2237 /// 2238 /// Decoding [`tbs::Signature`] is somewhat CPU-intensive (see benches in this 2239 /// crate), and when most of the result will be filtered away or completely 2240 /// unused, it makes sense to skip/delay decoding. 2241 #[derive(Clone, Copy, PartialEq, Eq, Hash, Encodable, Decodable, Serialize)] 2242 pub struct SpendableNoteUndecoded { 2243 // Need to keep this in sync with `tbs::Signature`, but there's a test 2244 // verifying they serialize and decode the same. 2245 #[serde(serialize_with = "serdect::array::serialize_hex_lower_or_bin")] 2246 pub signature: [u8; 48], 2247 pub spend_key: Keypair, 2248 } 2249 2250 impl fmt::Display for SpendableNoteUndecoded { 2251 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 2252 self.nonce().fmt(f) 2253 } 2254 } 2255 2256 impl fmt::Debug for SpendableNoteUndecoded { 2257 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 2258 f.debug_struct("SpendableNote") 2259 .field("nonce", &self.nonce()) 2260 .field("signature", &"[raw]") 2261 .field("spend_key", &self.spend_key) 2262 .finish() 2263 } 2264 } 2265 2266 impl SpendableNoteUndecoded { 2267 fn nonce(&self) -> Nonce { 2268 Nonce(self.spend_key.public_key()) 2269 } 2270 2271 pub fn decode(self) -> anyhow::Result<SpendableNote> { 2272 Ok(SpendableNote { 2273 signature: Decodable::consensus_decode_from_finite_reader( 2274 &mut self.signature.as_slice(), 2275 &ModuleRegistry::default(), 2276 )?, 2277 spend_key: self.spend_key, 2278 }) 2279 } 2280 } 2281 2282 /// An index used to deterministically derive [`Note`]s 2283 /// 2284 /// We allow converting it to u64 and incrementing it, but 2285 /// messing with it should be somewhat restricted to prevent 2286 /// silly errors. 2287 #[derive( 2288 Copy, 2289 Clone, 2290 Debug, 2291 Serialize, 2292 Deserialize, 2293 PartialEq, 2294 Eq, 2295 Encodable, 2296 Decodable, 2297 Default, 2298 PartialOrd, 2299 Ord, 2300 )] 2301 pub struct NoteIndex(u64); 2302 2303 impl NoteIndex { 2304 pub fn next(self) -> Self { 2305 Self(self.0 + 1) 2306 } 2307 2308 fn prev(self) -> Option<Self> { 2309 self.0.checked_sub(0).map(Self) 2310 } 2311 2312 pub fn as_u64(self) -> u64 { 2313 self.0 2314 } 2315 2316 // Private. If it turns out it is useful outside, 2317 // we can relax and convert to `From<u64>` 2318 // Actually used in tests RN, so cargo complains in non-test builds. 2319 #[allow(unused)] 2320 pub fn from_u64(v: u64) -> Self { 2321 Self(v) 2322 } 2323 2324 pub fn advance(&mut self) { 2325 *self = self.next(); 2326 } 2327 } 2328 2329 impl std::fmt::Display for NoteIndex { 2330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 2331 self.0.fmt(f) 2332 } 2333 } 2334 2335 struct OOBSpendTag; 2336 2337 impl sha256t::Tag for OOBSpendTag { 2338 fn engine() -> sha256::HashEngine { 2339 let mut engine = sha256::HashEngine::default(); 2340 engine.input(b"oob-spend"); 2341 engine 2342 } 2343 } 2344 2345 struct OOBReissueTag; 2346 2347 impl sha256t::Tag for OOBReissueTag { 2348 fn engine() -> sha256::HashEngine { 2349 let mut engine = sha256::HashEngine::default(); 2350 engine.input(b"oob-reissue"); 2351 engine 2352 } 2353 } 2354 2355 /// Determines the denominations to use when representing an amount 2356 /// 2357 /// Algorithm tries to leave the user with a target number of 2358 /// `denomination_sets` starting at the lowest denomination. `self` 2359 /// gives the denominations that the user already has. 2360 pub fn represent_amount<K>( 2361 amount: Amount, 2362 current_denominations: &TieredCounts, 2363 tiers: &Tiered<K>, 2364 denomination_sets: u16, 2365 fee_consensus: &FeeConsensus, 2366 ) -> TieredCounts { 2367 let mut remaining_amount = amount; 2368 let mut denominations = TieredCounts::default(); 2369 2370 // try to hit the target `denomination_sets` 2371 for tier in tiers.tiers() { 2372 let notes = current_denominations.get(*tier); 2373 let missing_notes = u64::from(denomination_sets).saturating_sub(notes as u64); 2374 let possible_notes = remaining_amount / (*tier + fee_consensus.fee(*tier)); 2375 2376 let add_notes = min(possible_notes, missing_notes); 2377 denominations.inc(*tier, add_notes as usize); 2378 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * add_notes; 2379 } 2380 2381 // if there is a remaining amount, add denominations with a greedy algorithm 2382 for tier in tiers.tiers().rev() { 2383 let res = remaining_amount / (*tier + fee_consensus.fee(*tier)); 2384 remaining_amount -= (*tier + fee_consensus.fee(*tier)) * res; 2385 denominations.inc(*tier, res as usize); 2386 } 2387 2388 let represented: u64 = denominations 2389 .iter() 2390 .map(|(k, v)| (k + fee_consensus.fee(k)).msats * (v as u64)) 2391 .sum(); 2392 2393 assert!(represented <= amount.msats); 2394 assert!(represented + fee_consensus.fee(Amount::from_msats(1)).msats >= amount.msats); 2395 2396 denominations 2397 } 2398 2399 pub(crate) fn create_bundle_for_inputs( 2400 inputs_and_notes: Vec<(ClientInput<MintInput>, SpendableNote)>, 2401 operation_id: OperationId, 2402 ) -> ClientInputBundle<MintInput, MintClientStateMachines> { 2403 let mut inputs = Vec::new(); 2404 let mut input_states = Vec::new(); 2405 2406 for (input, spendable_note) in inputs_and_notes { 2407 input_states.push((input.amount, spendable_note)); 2408 inputs.push(input); 2409 } 2410 2411 let input_sm = Arc::new(move |out_point_range: OutPointRange| { 2412 debug_assert_eq!(out_point_range.into_iter().count(), input_states.len()); 2413 2414 vec![MintClientStateMachines::Input(MintInputStateMachine { 2415 common: MintInputCommon { 2416 operation_id, 2417 out_point_range, 2418 }, 2419 state: MintInputStates::CreatedBundle(MintInputStateCreatedBundle { 2420 notes: input_states.clone(), 2421 }), 2422 })] 2423 }); 2424 2425 ClientInputBundle::new( 2426 inputs, 2427 vec![ClientInputSM { 2428 state_machines: input_sm, 2429 }], 2430 ) 2431 } 2432 2433 #[cfg(test)] 2434 mod tests { 2435 use std::collections::BTreeMap; 2436 use std::fmt::Display; 2437 use std::iter; 2438 use std::str::FromStr; 2439 2440 use bitcoin_hashes::Hash; 2441 use fedimint_core::config::FederationId; 2442 use fedimint_core::encoding::Decodable; 2443 use fedimint_core::invite_code::{InviteCode, InviteCodeV2}; 2444 use fedimint_core::module::registry::ModuleRegistry; 2445 use fedimint_core::util::SafeUrl; 2446 use fedimint_core::{ 2447 secp256k1, Amount, OutPoint, PeerId, Tiered, TieredCounts, TieredMulti, TransactionId, 2448 }; 2449 use fedimint_mint_common::config::FeeConsensus; 2450 use itertools::Itertools; 2451 use secp256k1::rand::rngs::OsRng; 2452 use secp256k1::{SecretKey, SECP256K1}; 2453 use serde_json::json; 2454 use tbs::Signature; 2455 2456 use crate::{ 2457 represent_amount, select_notes_from_stream, MintOperationMetaVariant, OOBNoteV2, OOBNotes, 2458 OOBNotesPart, OOBNotesV2, SpendableNote, SpendableNoteUndecoded, 2459 }; 2460 2461 #[test] 2462 fn represent_amount_targets_denomination_sets() { 2463 fn tiers(tiers: Vec<u64>) -> Tiered<()> { 2464 tiers 2465 .into_iter() 2466 .map(|tier| (Amount::from_sats(tier), ())) 2467 .collect() 2468 } 2469 2470 fn denominations(denominations: Vec<(Amount, usize)>) -> TieredCounts { 2471 TieredCounts::from_iter(denominations) 2472 } 2473 2474 let starting = notes(vec![ 2475 (Amount::from_sats(1), 1), 2476 (Amount::from_sats(2), 3), 2477 (Amount::from_sats(3), 2), 2478 ]) 2479 .summary(); 2480 let tiers = tiers(vec![1, 2, 3, 4]); 2481 2482 // target 3 tiers will fill out the 1 and 3 denominations 2483 assert_eq!( 2484 represent_amount( 2485 Amount::from_sats(6), 2486 &starting, 2487 &tiers, 2488 3, 2489 &FeeConsensus::zero() 2490 ), 2491 denominations(vec![(Amount::from_sats(1), 3), (Amount::from_sats(3), 1),]) 2492 ); 2493 2494 // target 2 tiers will fill out the 1 and 4 denominations 2495 assert_eq!( 2496 represent_amount( 2497 Amount::from_sats(6), 2498 &starting, 2499 &tiers, 2500 2, 2501 &FeeConsensus::zero() 2502 ), 2503 denominations(vec![(Amount::from_sats(1), 2), (Amount::from_sats(4), 1)]) 2504 ); 2505 } 2506 2507 #[test_log::test(tokio::test)] 2508 async fn select_notes_avg_test() { 2509 let max_amount = Amount::from_sats(1_000_000); 2510 let tiers = Tiered::gen_denominations(2, max_amount); 2511 let tiered = represent_amount::<()>( 2512 max_amount, 2513 &TieredCounts::default(), 2514 &tiers, 2515 3, 2516 &FeeConsensus::zero(), 2517 ); 2518 2519 let mut total_notes = 0; 2520 for multiplier in 1..100 { 2521 let stream = reverse_sorted_note_stream(tiered.iter().collect()); 2522 let select = select_notes_from_stream( 2523 stream, 2524 Amount::from_sats(multiplier * 1000), 2525 FeeConsensus::zero(), 2526 ) 2527 .await; 2528 total_notes += select.unwrap().into_iter_items().count(); 2529 } 2530 assert_eq!(total_notes / 100, 10); 2531 } 2532 2533 #[test_log::test(tokio::test)] 2534 async fn select_notes_returns_exact_amount_with_minimum_notes() { 2535 let f = || { 2536 reverse_sorted_note_stream(vec![ 2537 (Amount::from_sats(1), 10), 2538 (Amount::from_sats(5), 10), 2539 (Amount::from_sats(20), 10), 2540 ]) 2541 }; 2542 assert_eq!( 2543 select_notes_from_stream(f(), Amount::from_sats(7), FeeConsensus::zero()) 2544 .await 2545 .unwrap(), 2546 notes(vec![(Amount::from_sats(1), 2), (Amount::from_sats(5), 1)]) 2547 ); 2548 assert_eq!( 2549 select_notes_from_stream(f(), Amount::from_sats(20), FeeConsensus::zero()) 2550 .await 2551 .unwrap(), 2552 notes(vec![(Amount::from_sats(20), 1)]) 2553 ); 2554 } 2555 2556 #[test_log::test(tokio::test)] 2557 async fn select_notes_returns_next_smallest_amount_if_exact_change_cannot_be_made() { 2558 let stream = reverse_sorted_note_stream(vec![ 2559 (Amount::from_sats(1), 1), 2560 (Amount::from_sats(5), 5), 2561 (Amount::from_sats(20), 5), 2562 ]); 2563 assert_eq!( 2564 select_notes_from_stream(stream, Amount::from_sats(7), FeeConsensus::zero()) 2565 .await 2566 .unwrap(), 2567 notes(vec![(Amount::from_sats(5), 2)]) 2568 ); 2569 } 2570 2571 #[test_log::test(tokio::test)] 2572 async fn select_notes_uses_big_note_if_small_amounts_are_not_sufficient() { 2573 let stream = reverse_sorted_note_stream(vec![ 2574 (Amount::from_sats(1), 3), 2575 (Amount::from_sats(5), 3), 2576 (Amount::from_sats(20), 2), 2577 ]); 2578 assert_eq!( 2579 select_notes_from_stream(stream, Amount::from_sats(39), FeeConsensus::zero()) 2580 .await 2581 .unwrap(), 2582 notes(vec![(Amount::from_sats(20), 2)]) 2583 ); 2584 } 2585 2586 #[test_log::test(tokio::test)] 2587 async fn select_notes_returns_error_if_amount_is_too_large() { 2588 let stream = reverse_sorted_note_stream(vec![(Amount::from_sats(10), 1)]); 2589 let error = select_notes_from_stream(stream, Amount::from_sats(100), FeeConsensus::zero()) 2590 .await 2591 .unwrap_err(); 2592 assert_eq!(error.total_amount, Amount::from_sats(10)); 2593 } 2594 2595 fn reverse_sorted_note_stream( 2596 notes: Vec<(Amount, usize)>, 2597 ) -> impl futures::Stream<Item = (Amount, String)> { 2598 futures::stream::iter( 2599 notes 2600 .into_iter() 2601 // We are creating `number` dummy notes of `amount` value 2602 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number]) 2603 .sorted() 2604 .rev(), 2605 ) 2606 } 2607 2608 fn notes(notes: Vec<(Amount, usize)>) -> TieredMulti<String> { 2609 notes 2610 .into_iter() 2611 .flat_map(|(amount, number)| vec![(amount, "dummy note".into()); number]) 2612 .collect() 2613 } 2614 2615 #[test] 2616 fn decoding_empty_oob_notes_fails() { 2617 let empty_oob_notes = 2618 OOBNotes::new(FederationId::dummy().to_prefix(), TieredMulti::default()); 2619 let oob_notes_string = empty_oob_notes.to_string(); 2620 2621 let res = oob_notes_string.parse::<OOBNotes>(); 2622 2623 assert!(res.is_err(), "An empty OOB notes string should not parse"); 2624 } 2625 2626 fn test_roundtrip_serialize_str<T, F>(data: T, assertions: F) 2627 where 2628 T: FromStr + Display, 2629 <T as FromStr>::Err: std::fmt::Debug, 2630 F: Fn(T), 2631 { 2632 let data_str = data.to_string(); 2633 assertions(data); 2634 let data_parsed = data_str.parse().expect("Deserialization failed"); 2635 assertions(data_parsed); 2636 } 2637 2638 #[test] 2639 fn notes_encode_decode() { 2640 let federation_id_1 = 2641 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x21; 32])); 2642 let federation_id_prefix_1 = federation_id_1.to_prefix(); 2643 let federation_id_2 = 2644 FederationId(bitcoin_hashes::sha256::Hash::from_byte_array([0x42; 32])); 2645 let federation_id_prefix_2 = federation_id_2.to_prefix(); 2646 2647 let notes = vec![( 2648 Amount::from_sats(1), 2649 SpendableNote::consensus_decode_hex("a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd", &ModuleRegistry::default()).unwrap(), 2650 )] 2651 .into_iter() 2652 .collect::<TieredMulti<_>>(); 2653 2654 // Can decode inviteless notes 2655 let notes_no_invite = OOBNotes::new(federation_id_prefix_1, notes.clone()); 2656 test_roundtrip_serialize_str(notes_no_invite, |oob_notes| { 2657 assert_eq!(oob_notes.notes(), ¬es); 2658 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1); 2659 assert_eq!(oob_notes.federation_invite(), None); 2660 }); 2661 2662 // Can decode notes with invite 2663 let invite = InviteCode::new( 2664 "wss://foo.bar".parse().unwrap(), 2665 PeerId::from(0), 2666 federation_id_1, 2667 None, 2668 ); 2669 let notes_invite = OOBNotes::new_with_invite(notes.clone(), &invite); 2670 test_roundtrip_serialize_str(notes_invite, |oob_notes| { 2671 assert_eq!(oob_notes.notes(), ¬es); 2672 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1); 2673 assert_eq!(oob_notes.federation_invite(), Some(invite.clone())); 2674 }); 2675 2676 // Can decode notes without federation id prefix, so we can optionally remove it 2677 // in the future 2678 let notes_no_prefix = OOBNotes(vec![ 2679 OOBNotesPart::Notes(notes.clone()), 2680 OOBNotesPart::Invite { 2681 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())], 2682 federation_id: federation_id_1, 2683 }, 2684 ]); 2685 test_roundtrip_serialize_str(notes_no_prefix, |oob_notes| { 2686 assert_eq!(oob_notes.notes(), ¬es); 2687 assert_eq!(oob_notes.federation_id_prefix(), federation_id_prefix_1); 2688 }); 2689 2690 // Rejects notes with inconsistent federation id 2691 let notes_inconsistent = OOBNotes(vec![ 2692 OOBNotesPart::Notes(notes), 2693 OOBNotesPart::Invite { 2694 peer_apis: vec![(PeerId::from(0), "wss://foo.bar".parse().unwrap())], 2695 federation_id: federation_id_1, 2696 }, 2697 OOBNotesPart::FederationIdPrefix(federation_id_prefix_2), 2698 ]); 2699 let notes_inconsistent_str = notes_inconsistent.to_string(); 2700 assert!(notes_inconsistent_str.parse::<OOBNotes>().is_err()); 2701 } 2702 2703 #[test] 2704 fn oob_notes_v2_encode_base64_roundtrip() { 2705 const NUMBER_OF_NOTES: usize = 5; 2706 2707 let notes = OOBNotesV2 { 2708 mint: InviteCodeV2 { 2709 id: FederationId::dummy(), 2710 peers: BTreeMap::from_iter([( 2711 PeerId::from(0), 2712 SafeUrl::parse("https://mint.com").expect("Url is valid"), 2713 )]), 2714 api_secret: None, 2715 }, 2716 notes: iter::repeat(OOBNoteV2 { 2717 amount: Amount::from_msats(1), 2718 sig: Signature(bls12_381::G1Affine::generator()), 2719 key: SecretKey::new(&mut OsRng).keypair(SECP256K1), 2720 }) 2721 .take(NUMBER_OF_NOTES) 2722 .collect(), 2723 memo: "Here are your sats!".to_string(), 2724 }; 2725 2726 OOBNotes::from_str(¬es.encode_base64()).expect("Failed to decode to legacy OOBNotes"); 2727 2728 let encoded = notes.encode_base64(); 2729 let decoded = OOBNotesV2::decode_base64(&encoded).unwrap(); 2730 2731 assert_eq!(notes, decoded); 2732 } 2733 2734 #[test] 2735 fn spendable_note_undecoded_sanity() { 2736 // TODO: add more hex dumps to the loop 2737 #[allow(clippy::single_element_loop)] 2738 for note_hex in ["a5dd3ebacad1bc48bd8718eed5a8da1d68f91323bef2848ac4fa2e6f8eed710f3178fd4aef047cc234e6b1127086f33cc408b39818781d9521475360de6b205f3328e490a6d99d5e2553a4553207c8bd"] { 2739 2740 let note = SpendableNote::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap(); 2741 let note_undecoded= SpendableNoteUndecoded::consensus_decode_hex(note_hex, &ModuleRegistry::default()).unwrap().decode().unwrap(); 2742 assert_eq!( 2743 note, 2744 note_undecoded, 2745 ); 2746 assert_eq!( 2747 serde_json::to_string(¬e).unwrap(), 2748 serde_json::to_string(¬e_undecoded).unwrap(), 2749 ); 2750 } 2751 } 2752 2753 #[test] 2754 fn reissuance_meta_compatibility_02_03() { 2755 let dummy_outpoint = OutPoint { 2756 txid: TransactionId::all_zeros(), 2757 out_idx: 0, 2758 }; 2759 2760 let old_meta_json = json!({ 2761 "reissuance": { 2762 "out_point": dummy_outpoint 2763 } 2764 }); 2765 2766 let old_meta: MintOperationMetaVariant = 2767 serde_json::from_value(old_meta_json).expect("parsing old reissuance meta failed"); 2768 assert_eq!( 2769 old_meta, 2770 MintOperationMetaVariant::Reissuance { 2771 legacy_out_point: Some(dummy_outpoint), 2772 txid: None, 2773 out_point_indices: vec![], 2774 } 2775 ); 2776 2777 let new_meta_json = serde_json::to_value(MintOperationMetaVariant::Reissuance { 2778 legacy_out_point: None, 2779 txid: Some(dummy_outpoint.txid), 2780 out_point_indices: vec![0], 2781 }) 2782 .expect("serializing always works"); 2783 assert_eq!( 2784 new_meta_json, 2785 json!({ 2786 "reissuance": { 2787 "txid": dummy_outpoint.txid, 2788 "out_point_indices": [dummy_outpoint.out_idx], 2789 } 2790 }) 2791 ); 2792 } 2793 }