From 02366294f85a4117200d3eee50bd05df16ca7bb3 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 26 Jan 2024 06:21:20 -0600 Subject: [PATCH 01/17] Bump fedimint to v0.2.2 --- Cargo.lock | 85 +++++++++++++++++------------------------- mutiny-core/Cargo.toml | 14 +++---- mutiny-wasm/Cargo.toml | 2 +- 3 files changed, 42 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a1a560ba..092cbec81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -864,9 +864,8 @@ checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "fedimint-aead" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce880ad0418fb78a32a52024837fea7fb46dac72114b0ff8c14e1c7322652d9a" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "argon2", @@ -877,9 +876,8 @@ dependencies = [ [[package]] name = "fedimint-bip39" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ffaaabb0c933b41b57649c1c85863d2d651ef67fc0c399295404936fb6dfaf" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "bip39", "fedimint-client", @@ -889,9 +887,8 @@ dependencies = [ [[package]] name = "fedimint-bitcoind" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53798a33b0fbe9104dc19d4f7ee6418348aaa9bc90873d9a5006250a74e4a602" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "async-trait", @@ -910,18 +907,16 @@ dependencies = [ [[package]] name = "fedimint-build" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d10db0086d5c21a4f6068ecdbccd3ea3c73d0c21824373c08d74028cf0919c" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "serde_json", ] [[package]] name = "fedimint-client" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "954dc66cabec64c11a902035986e6701c3d7dad3cf75ea94cc96d7f2c4f7c073" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "aquamarine", @@ -950,9 +945,8 @@ dependencies = [ [[package]] name = "fedimint-core" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ab5e7b0ffc2fdfded616de12af2578ac3a547409e4e87d45f61e59529c64850" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "async-lock 2.8.0", @@ -1002,9 +996,8 @@ dependencies = [ [[package]] name = "fedimint-derive" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef091aedc12ea70635f9b811f6462ecceb11b318a08586a512ba34065720f49f" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "itertools 0.11.0", "proc-macro2", @@ -1014,9 +1007,8 @@ dependencies = [ [[package]] name = "fedimint-derive-secret" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc94284d491cf3f202d1da83cd6e4f07e7b4435cfa8d4f556316fa1701278f5" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "fedimint-core", @@ -1028,18 +1020,16 @@ dependencies = [ [[package]] name = "fedimint-hkdf" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbb78611fe88ca0c3df1f268aaf3f14d595750082c2312b65bf6fc670239ec11" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "bitcoin_hashes 0.11.0", ] [[package]] name = "fedimint-ln-client" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8085e06daf6cbe0834f0bbe2f6cce64c6537375cb199e6ddfcfcbcb999a1913d" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "aquamarine", @@ -1072,9 +1062,8 @@ dependencies = [ [[package]] name = "fedimint-ln-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7f7e05f96a596d63bb35c86cafa5c0d5ac5d38bb211d3d88cf1d08f58af79a" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "aquamarine", @@ -1103,9 +1092,8 @@ dependencies = [ [[package]] name = "fedimint-logging" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac96e0c22ef3d237c9dec7ffb9315c4803b218283167b46a64d62a825fa18995" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "tracing-subscriber", @@ -1113,9 +1101,8 @@ dependencies = [ [[package]] name = "fedimint-mint-client" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd1decc8213d7dfa92c569a15505db0a7d6733098249459f37edab2675055cc4" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "aquamarine", @@ -1149,9 +1136,8 @@ dependencies = [ [[package]] name = "fedimint-mint-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2cb350eac9739a7efb2a99a1f40faa69e89d34f9abd53917ab2c3232929f13e" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "async-trait", @@ -1174,9 +1160,8 @@ dependencies = [ [[package]] name = "fedimint-tbs" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95f83f4c8995a667168a100797b6eb2cfc345c70bb34e87c603405cc982e6707" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "bitcoin_hashes 0.11.0", "bls12_381", @@ -1212,9 +1197,8 @@ dependencies = [ [[package]] name = "fedimint-wallet-client" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c2fd8bbab9806d04d55a5b562ddf651d24379acb442e59f2f08e9139ee5b79" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "aquamarine", @@ -1244,9 +1228,8 @@ dependencies = [ [[package]] name = "fedimint-wallet-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42cf66b1f0d70e21dba17f53542346141036e523933f614a2c122621e1677ade" +version = "0.2.2-rc7" +source = "git+https://github.com/fedimint/fedimint?rev=989282cb118c25f6f6dce62d0727f61cfd6ec3d9#989282cb118c25f6f6dce62d0727f61cfd6ec3d9" dependencies = [ "anyhow", "async-trait", diff --git a/mutiny-core/Cargo.toml b/mutiny-core/Cargo.toml index 5462d2dd3..f1b27184b 100644 --- a/mutiny-core/Cargo.toml +++ b/mutiny-core/Cargo.toml @@ -49,13 +49,13 @@ bincode = "1.3.3" hex = "0.4.3" async-lock = "3.2.0" -fedimint-client = "0.2.1" -fedimint-core = "0.2.1" -fedimint-wallet-client = "0.2.1" -fedimint-mint-client = "0.2.1" -fedimint-ln-client = "0.2.1" -fedimint-bip39 = "0.2.1" -fedimint-ln-common = "0.2.1" +fedimint-client = { git = "https://github.com/fedimint/fedimint", rev = "989282cb118c25f6f6dce62d0727f61cfd6ec3d9" } +fedimint-core = { git = "https://github.com/fedimint/fedimint", rev = "989282cb118c25f6f6dce62d0727f61cfd6ec3d9" } +fedimint-wallet-client = { git = "https://github.com/fedimint/fedimint", rev = "989282cb118c25f6f6dce62d0727f61cfd6ec3d9" } +fedimint-mint-client = { git = "https://github.com/fedimint/fedimint", rev = "989282cb118c25f6f6dce62d0727f61cfd6ec3d9" } +fedimint-ln-client = { git = "https://github.com/fedimint/fedimint", rev = "989282cb118c25f6f6dce62d0727f61cfd6ec3d9" } +fedimint-bip39 = { git = "https://github.com/fedimint/fedimint", rev = "989282cb118c25f6f6dce62d0727f61cfd6ec3d9" } +fedimint-ln-common = { git = "https://github.com/fedimint/fedimint", rev = "989282cb118c25f6f6dce62d0727f61cfd6ec3d9" } base64 = "0.13.0" pbkdf2 = "0.11" diff --git a/mutiny-wasm/Cargo.toml b/mutiny-wasm/Cargo.toml index 27475714b..e8e0875c7 100644 --- a/mutiny-wasm/Cargo.toml +++ b/mutiny-wasm/Cargo.toml @@ -42,7 +42,7 @@ futures = "0.3.25" urlencoding = "2.1.2" once_cell = "1.18.0" payjoin = { version = "0.13.0", features = ["send", "base64"] } -fedimint-core = "0.2.1" +fedimint-core = { git = "https://github.com/fedimint/fedimint", rev = "989282cb118c25f6f6dce62d0727f61cfd6ec3d9" } # The `console_error_panic_hook` crate provides better debugging of panics by # logging them with `console.error`. This is great for development, but requires From a44282b9505a6eaf4d5936dd8ee1456c82426532 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 26 Jan 2024 08:49:29 -0600 Subject: [PATCH 02/17] Remove and delete previous federation data --- mutiny-core/src/federation.rs | 21 ++++++++++++++++++--- mutiny-core/src/lib.rs | 1 + mutiny-core/src/storage.rs | 6 +++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index 733c1905a..c5c1f75a4 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -150,6 +150,7 @@ pub(crate) struct FederationClient { pub(crate) uuid: String, pub(crate) fedimint_client: ClientArc, storage: S, + fedimint_storage: FedimintStorage, pub(crate) logger: Arc, } @@ -184,13 +185,13 @@ impl FederationClient { client_builder.with_module(LightningClientInit); log_trace!(logger, "Building fedimint client db"); - let db = FedimintStorage::new( + let fedimint_storage = FedimintStorage::new( storage.clone(), federation_info.federation_id().to_string(), logger.clone(), ) - .await? - .into(); + .await?; + let db = fedimint_storage.clone().into(); if get_config_from_db(&db).await.is_none() { client_builder.with_federation_info(federation_info.clone()); @@ -226,6 +227,7 @@ impl FederationClient { Ok(FederationClient { uuid, fedimint_client, + fedimint_storage, storage: storage.clone(), logger, }) @@ -527,6 +529,10 @@ impl FederationClient { welcome_message: self.fedimint_client.get_meta("welcome_message"), } } + + pub async fn delete_fedimint_storage(&self) -> Result<(), MutinyError> { + self.fedimint_storage.delete_store().await + } } // A federation private key will be derived from @@ -770,6 +776,15 @@ impl FedimintStorage { fedimint_memory: Arc::new(fedimint_memory), }) } + + pub async fn delete_store(&self) -> Result<(), MutinyError> { + let mut mem_db_tx = self.begin_transaction().await; + mem_db_tx.raw_remove_by_prefix(&[]).await?; + mem_db_tx + .commit_tx() + .await + .map_err(|_| MutinyError::write_err(MutinyStorageError::IndexedDBError)) + } } fn key_id(federation_id: &str) -> String { diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 019f9dd12..e927c9f22 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -1702,6 +1702,7 @@ impl MutinyWallet { self.storage .insert_federations(federation_storage_guard.clone()) .await?; + fedimint_client.delete_fedimint_storage().await?; federations_guard.remove(&federation_id); } else { return Err(MutinyError::NotFound); diff --git a/mutiny-core/src/storage.rs b/mutiny-core/src/storage.rs index 1ad82bf82..93335c201 100644 --- a/mutiny-core/src/storage.rs +++ b/mutiny-core/src/storage.rs @@ -374,7 +374,11 @@ pub trait MutinyStorage: Clone + Sized + Send + Sync + 'static { } /// Inserts the federation indexes into storage - async fn insert_federations(&self, federations: FederationStorage) -> Result<(), MutinyError> { + async fn insert_federations( + &self, + mut federations: FederationStorage, + ) -> Result<(), MutinyError> { + federations.version += 1; let version = Some(federations.version); self.set_data_async(FEDERATIONS_KEY.to_string(), federations, version) .await From a767626251b9659fd1a524bfa604023195f5e5d3 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 26 Jan 2024 17:57:38 -0600 Subject: [PATCH 03/17] Scan for fedimint ecash --- mutiny-core/src/federation.rs | 11 +++++++++++ mutiny-core/src/lib.rs | 17 +++++++++++++++++ mutiny-wasm/src/lib.rs | 8 ++++++++ 3 files changed, 36 insertions(+) diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index c5c1f75a4..e3cb68c5c 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -533,6 +533,17 @@ impl FederationClient { pub async fn delete_fedimint_storage(&self) -> Result<(), MutinyError> { self.fedimint_storage.delete_store().await } + + pub async fn recover_backup(&self) -> Result<(), MutinyError> { + self.fedimint_client + .restore_from_backup(None) + .await + .map_err(|e| { + log_error!(self.logger, "Could not restore from backup: {}", e); + e + })?; + Ok(()) + } } // A federation private key will be derived from diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index e927c9f22..5f317b086 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -1714,6 +1714,23 @@ impl MutinyWallet { Ok(()) } + pub async fn recover_federation_backups(&self) -> Result<(), MutinyError> { + let federation_ids = self.list_federation_ids().await?; + + let federations = self.federations.read().await; + for fed_id in federation_ids { + federations + .get(&fed_id) + .ok_or(MutinyError::NotFound)? + .recover_backup() + .await?; + + log_info!(self.logger, "Scanned federation backups: {}", fed_id); + } + + Ok(()) + } + pub async fn get_total_federation_balance(&self) -> Result { let federation_ids = self.list_federation_ids().await?; let mut total_balance = 0; diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index fd34a152b..18d45b9d2 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -1116,6 +1116,14 @@ impl MutinyWallet { Ok(self.inner.get_federation_balances().await?.into()) } + /// Scans all federations for user ecash. + /// + /// This can be useful if you think you have some federation funds missing. + #[wasm_bindgen] + pub async fn recover_federation_backups(&mut self) -> Result<(), MutinyJsError> { + Ok(self.inner.recover_federation_backups().await?) + } + pub fn get_address_labels( &self, ) -> Result> */, MutinyJsError> { From 5c3461284357f35cdf931818a7dd4f06ed53099a Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 26 Jan 2024 19:37:32 -0600 Subject: [PATCH 04/17] set preferred federation gateways --- mutiny-core/src/federation.rs | 270 +++++++++++++++++++++++++++++++++- 1 file changed, 269 insertions(+), 1 deletion(-) diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index e3cb68c5c..85b1ef7d8 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -60,8 +60,11 @@ use lightning::{ }; use lightning_invoice::Bolt11Invoice; use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use std::sync::atomic::{AtomicU32, Ordering}; use std::{collections::HashMap, fmt::Debug, sync::Arc}; +use std::{ + str::FromStr, + sync::atomic::{AtomicU32, Ordering}, +}; // The amount of time in milliseconds to wait for // checking the status of a fedimint payment. This @@ -76,6 +79,12 @@ const FEDIMINT_OPERATIONS_LIST_MAX: usize = 100; pub const FEDIMINTS_PREFIX_KEY: &str = "fedimints/"; +// Default signet/mainnet federation gateway info +const SIGNET_GATEWAY: &str = "0256f5ef1d986e9abf559651b7167de28bfd954683cd0f14703be12d1421aedc55"; +const MAINNET_GATEWAY: &str = "025b9f090d3daab012346701f27d1c220d6d290f6b498255cddc492c255532a09d"; +const SIGNET_FEDERATION: &str = "c8d423964c7ad944d30f57359b6e5b260e211dcfdb945140e28d4df51fd572d2"; +const MAINNET_FEDERATION: &str = "c36038cce5a97e3467f03336fa8e7e3410960b81d1865cda2a609f70a8f51efb"; + impl From for HTLCStatus { fn from(state: LnReceiveState) -> Self { match state { @@ -223,6 +232,37 @@ impl FederationClient { return Err(MutinyError::NetworkMismatch); } + // Set active gateway preference + let lightning_module = fedimint_client.get_first_module::(); + let gateways = lightning_module + .fetch_registered_gateways() + .await + .map_err(|e| { + log_warn!( + logger, + "Could not fetch gateways from federation {}: {e}", + federation_info.federation_id() + ) + }); + + if let Ok(gateways) = gateways { + if let Some(a) = get_gateway_preference(gateways, fedimint_client.federation_id()) { + log_info!( + logger, + "Setting active gateway for federation {}: {:?}", + federation_info.federation_id(), + a + ); + let _ = lightning_module.set_active_gateway(&a).await.map_err(|e| { + log_warn!( + logger, + "Could not set gateway for federation {}: {e}", + federation_info.federation_id() + ) + }); + } + } + log_debug!(logger, "Built fedimint client"); Ok(FederationClient { uuid, @@ -546,6 +586,52 @@ impl FederationClient { } } +// Get a preferred gateway from a federation +fn get_gateway_preference( + gateways: Vec, + federation_id: FederationId, +) -> Option { + let mut active_choice: Option = None; + + let signet_gateway_id = + bitcoin::secp256k1::PublicKey::from_str(SIGNET_GATEWAY).expect("should be valid pubkey"); + let mainnet_gateway_id = + bitcoin::secp256k1::PublicKey::from_str(MAINNET_GATEWAY).expect("should be valid pubkey"); + let signet_federation_id = + FederationId::from_str(SIGNET_FEDERATION).expect("should be a valid federation id"); + let mainnet_federation_id = + FederationId::from_str(MAINNET_FEDERATION).expect("should be a valid federation id"); + + for g in gateways { + let g_id = g.info.gateway_id; + + // if the gateway node ID matches what we expect for our signet/mainnet + // these take the highest priority + if (g_id == signet_gateway_id && federation_id == signet_federation_id) + || (g_id == mainnet_gateway_id && federation_id == mainnet_federation_id) + { + return Some(g_id); + } + + // if vetted, set up as current active choice + if g.vetted { + active_choice = Some(g_id); + continue; + } + + // if not vetted, make sure fee is high enough + if active_choice.is_none() { + let fees = g.info.fees; + if fees.base_msat >= 1_000 && fees.proportional_millionths >= 100 { + active_choice = Some(g_id); + continue; + } + } + } + + active_choice +} + // A federation private key will be derived from // `m/1'/N'` where `N` is the network type. // @@ -956,6 +1042,178 @@ fn fedimint_mnemonic_generation() { assert_eq!(expected_child_mnemonic2, child_mnemonic2.to_string()); } +#[cfg(test)] +fn gateway_preference() { + use fedimint_core::util::SafeUrl; + use fedimint_ln_common::{LightningGateway, LightningGatewayAnnouncement}; + use lightning_invoice::RoutingFees; + use std::time::Duration; + + use super::*; + + const RANDOM_KEY: &str = "0218845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166"; + let random_key = bitcoin::secp256k1::PublicKey::from_str(RANDOM_KEY).unwrap(); + + const VETTED_GATEWAY: &str = + "02465ed5be53d04fde66c9418ff14a5f2267723810176c9212b722e542dc1afb1b"; + let vetted_gateway_pubkey = bitcoin::secp256k1::PublicKey::from_str(VETTED_GATEWAY).unwrap(); + + const UNVETTED_GATEWAY_KEY_HIGH_FEE: &str = + "0384526253c27c7aef56c7b71a5cd25bebb66dddda437826defc5b2568bde81f07"; + let unvetted_gateway_high_fee_pubkey = + bitcoin::secp256k1::PublicKey::from_str(UNVETTED_GATEWAY_KEY_HIGH_FEE).unwrap(); + + const UNVETTED_GATEWAY_KEY_LOW_FEE: &str = + "02e6642fd69bd211f93f7f1f36ca51a26a5290eb2dd1b0d8279a87bb0d480c8443"; + let unvetted_gateway_low_fee_pubkey = + bitcoin::secp256k1::PublicKey::from_str(UNVETTED_GATEWAY_KEY_LOW_FEE).unwrap(); + + let random_federation_id = FederationId::dummy(); + + // Create some sample LightningGatewayAnnouncement structs to test with + let signet_gateway = LightningGatewayAnnouncement { + info: LightningGateway { + mint_channel_id: 12345, + gateway_redeem_key: bitcoin::secp256k1::PublicKey::from_str(SIGNET_GATEWAY).unwrap(), + node_pub_key: random_key, + lightning_alias: "Signet Gateway".to_string(), + api: SafeUrl::parse("http://localhost:8080").unwrap(), + route_hints: vec![], + fees: RoutingFees { + base_msat: 100, + proportional_millionths: 10, + }, + gateway_id: bitcoin::secp256k1::PublicKey::from_str(SIGNET_GATEWAY).unwrap(), + supports_private_payments: true, + }, + vetted: false, + ttl: Duration::from_secs(3600), + }; + + let mainnet_gateway = LightningGatewayAnnouncement { + info: LightningGateway { + mint_channel_id: 12345, + gateway_redeem_key: bitcoin::secp256k1::PublicKey::from_str(MAINNET_GATEWAY).unwrap(), + node_pub_key: random_key, + lightning_alias: "Mainnet Gateway".to_string(), + api: SafeUrl::parse("http://localhost:8080").unwrap(), + route_hints: vec![], + fees: RoutingFees { + base_msat: 100, + proportional_millionths: 10, + }, + gateway_id: bitcoin::secp256k1::PublicKey::from_str(MAINNET_GATEWAY).unwrap(), + supports_private_payments: true, + }, + vetted: false, + ttl: Duration::from_secs(3600), + }; + + let vetted_gateway = LightningGatewayAnnouncement { + info: LightningGateway { + mint_channel_id: 12345, + gateway_redeem_key: random_key, + node_pub_key: vetted_gateway_pubkey, + lightning_alias: "Vetted Gateway".to_string(), + api: SafeUrl::parse("http://localhost:8080").unwrap(), + route_hints: vec![], + fees: RoutingFees { + base_msat: 200, + proportional_millionths: 20, + }, + gateway_id: vetted_gateway_pubkey, + supports_private_payments: true, + }, + vetted: true, + ttl: Duration::from_secs(3600), + }; + + let unvetted_gateway_high_fee = LightningGatewayAnnouncement { + info: LightningGateway { + mint_channel_id: 12345, + gateway_redeem_key: random_key, + node_pub_key: unvetted_gateway_high_fee_pubkey, + lightning_alias: "Unvetted Gateway".to_string(), + api: SafeUrl::parse("http://localhost:8080").unwrap(), + route_hints: vec![], + fees: RoutingFees { + base_msat: 200, + proportional_millionths: 20, + }, + gateway_id: unvetted_gateway_high_fee_pubkey, + supports_private_payments: true, + }, + vetted: false, + ttl: Duration::from_secs(3600), + }; + + let unvetted_gateway_low_fee = LightningGatewayAnnouncement { + info: LightningGateway { + mint_channel_id: 12345, + gateway_redeem_key: random_key, + node_pub_key: unvetted_gateway_low_fee_pubkey, + lightning_alias: "Unvetted Gateway".to_string(), + api: SafeUrl::parse("http://localhost:8080").unwrap(), + route_hints: vec![], + fees: RoutingFees { + base_msat: 10, + proportional_millionths: 1, + }, + gateway_id: unvetted_gateway_low_fee_pubkey, + supports_private_payments: true, + }, + vetted: false, + ttl: Duration::from_secs(3600), + }; + + let gateways = vec![ + signet_gateway.clone(), + mainnet_gateway.clone(), + vetted_gateway.clone(), + unvetted_gateway_low_fee.clone(), + unvetted_gateway_high_fee.clone(), + ]; + + // Test that the method returns a Gateway ID when given a matching federation ID and gateway ID + let signet_federation_id = FederationId::from_str(SIGNET_FEDERATION).unwrap(); + assert_eq!( + get_gateway_preference(gateways.clone(), signet_federation_id), + Some(bitcoin::secp256k1::PublicKey::from_str(SIGNET_GATEWAY).unwrap()) + ); + + let mainnet_federation_id = FederationId::from_str(MAINNET_FEDERATION).unwrap(); + assert_eq!( + get_gateway_preference(gateways.clone(), mainnet_federation_id), + Some(bitcoin::secp256k1::PublicKey::from_str(MAINNET_GATEWAY).unwrap()) + ); + + // Test that the method returns the first vetted gateway if none of the gateways match the federation ID + assert_eq!( + get_gateway_preference(gateways, random_federation_id), + Some(vetted_gateway_pubkey) + ); + + // Test that the method returns the first vetted gateway if none of the gateways match the federation ID + let gateways = vec![ + unvetted_gateway_low_fee.clone(), + unvetted_gateway_high_fee.clone(), + vetted_gateway.clone(), + ]; + assert_eq!( + get_gateway_preference(gateways, random_federation_id), + Some(vetted_gateway_pubkey) + ); + + // Test that the method returns None when given a non-matching federation ID and gateway ID, + // and no unvetted gateways with a high enough fee + let gateways = vec![ + signet_gateway, + mainnet_gateway, + unvetted_gateway_low_fee.clone(), + ]; + assert_eq!(get_gateway_preference(gateways, random_federation_id), None); +} + #[cfg(test)] #[cfg(not(target_arch = "wasm32"))] mod tests { @@ -970,6 +1228,11 @@ mod tests { fn test_fedimint_mnemonic_generation() { fedimint_mnemonic_generation(); } + + #[test] + fn test_gateway_preference() { + gateway_preference(); + } } #[cfg(test)] @@ -990,4 +1253,9 @@ mod wasm_tests { fn test_fedimint_mnemonic_generation() { fedimint_mnemonic_generation(); } + + #[test] + fn test_gateway_preference() { + gateway_preference(); + } } From eabf31f93b802a4a00470e5cb6d055d4a0aa0b8c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 29 Jan 2024 08:41:31 -0600 Subject: [PATCH 05/17] Delay batched remote storage for fedimint --- mutiny-core/src/federation.rs | 4 +- mutiny-core/src/storage.rs | 91 ++++++++++++++++++++++++++++++++++- mutiny-wasm/src/indexed_db.rs | 7 +++ 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index 85b1ef7d8..9d992a003 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -931,9 +931,9 @@ impl<'a, S: MutinyStorage> IRawDatabaseTransaction for IndexedDBPseudoTransactio version, value: serde_json::to_value(hex_serialized_data).unwrap(), }; - // TODO await on persisting remotely self.storage - .set_data(key_id(&self.federation_id), value, Some(version))?; + .set_data_async_queue_remote(key_id(&self.federation_id), value, version) + .await?; Ok(()) } diff --git a/mutiny-core/src/storage.rs b/mutiny-core/src/storage.rs index 93335c201..0574aeab6 100644 --- a/mutiny-core/src/storage.rs +++ b/mutiny-core/src/storage.rs @@ -1,4 +1,3 @@ -use crate::ldkstorage::CHANNEL_MANAGER_KEY; use crate::logging::MutinyLogger; use crate::nodemanager::{NodeStorage, DEVICE_LOCK_INTERVAL_SECS}; use crate::utils::{now, spawn}; @@ -11,11 +10,13 @@ use crate::{ error::{MutinyError, MutinyStorageError}, event::PaymentInfo, }; +use crate::{ldkstorage::CHANNEL_MANAGER_KEY, utils::sleep}; use async_trait::async_trait; use bdk::chain::{Append, PersistBackend}; use bip39::Mnemonic; use bitcoin::hashes::hex::ToHex; use bitcoin::hashes::Hash; +use futures_util::lock::Mutex; use hex::FromHex; use lightning::{ln::PaymentHash, util::logger::Logger}; use lightning::{log_error, log_trace}; @@ -40,6 +41,25 @@ pub(crate) const EXPECTED_NETWORK_KEY: &str = "network"; const PAYMENT_INBOUND_PREFIX_KEY: &str = "payment_inbound/"; const PAYMENT_OUTBOUND_PREFIX_KEY: &str = "payment_outbound/"; pub const LAST_DM_SYNC_TIME_KEY: &str = "last_dm_sync_time"; +const DELAYED_WRITE_MS: i32 = 50; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DelayedKeyValueItem { + pub key: String, + pub value: Value, + pub version: u32, + pub write_time: u128, +} + +impl From for VssKeyValueItem { + fn from(item: DelayedKeyValueItem) -> Self { + VssKeyValueItem { + key: item.key, + value: item.value, + version: item.version, + } + } +} fn needs_encryption(key: &str) -> bool { match key { @@ -213,6 +233,65 @@ pub trait MutinyStorage: Clone + Sized + Send + Sync + 'static { Ok(()) } + fn get_delayed_objects(&self) -> Arc>>; + + /// Set a value to persist in local storage, queues remote save + /// The function will encrypt the value if needed + async fn set_data_async_queue_remote( + &self, + key: String, + value: T, + version: u32, + ) -> Result<(), MutinyError> + where + T: Serialize + Send, + { + let data = serde_json::to_value(value).map_err(|e| MutinyError::PersistenceFailed { + source: MutinyStorageError::SerdeError { source: e }, + })?; + + // save locally first + let local_data = data.clone(); + let key_clone = key.clone(); + let json: Value = encrypt_value(key_clone.clone(), local_data, self.cipher())?; + self.set_async(key_clone, json).await?; + + // save to VSS if it is enabled + // queue up keys to persist later + if let Some(vss) = self.vss_client() { + let initial_write_time = now().as_millis(); + let item = DelayedKeyValueItem { + key: key.clone(), + value: data, + version, + write_time: initial_write_time, + }; + + let delayed_lock = self.get_delayed_objects(); + let mut delayed_keys = delayed_lock.lock().await; + delayed_keys.insert(key.clone(), item.clone()); + drop(delayed_keys); + + let delayed_keys_ref = self.get_delayed_objects(); + let original_item = item.clone(); + spawn(async move { + sleep(DELAYED_WRITE_MS).await; + + let threaded_keys = delayed_keys_ref.lock().await; + + if let Some(key_to_check) = threaded_keys.get(&key) { + if key_to_check.write_time == initial_write_time { + drop(threaded_keys); + + let _ = vss.put_objects(vec![original_item.into()]).await; + } + } + }); + } + + Ok(()) + } + /// Get a value from the storage, use get_data if you want the value to be decrypted fn get(&self, key: impl AsRef) -> Result, MutinyError> where @@ -481,6 +560,7 @@ pub struct MemoryStorage { pub cipher: Option, pub memory: Arc>>, pub vss_client: Option>, + delayed_keys: Arc>>, } impl MemoryStorage { @@ -494,6 +574,7 @@ impl MemoryStorage { password, memory: Arc::new(RwLock::new(HashMap::new())), vss_client, + delayed_keys: Arc::new(Mutex::new(HashMap::new())), } } @@ -629,6 +710,10 @@ impl MutinyStorage for MemoryStorage { async fn fetch_device_lock(&self) -> Result, MutinyError> { self.get_device_lock() } + + fn get_delayed_objects(&self) -> Arc>> { + self.delayed_keys.clone() + } } // Dummy implementation for testing or if people want to ignore persistence @@ -695,6 +780,10 @@ impl MutinyStorage for () { async fn fetch_device_lock(&self) -> Result, MutinyError> { self.get_device_lock() } + + fn get_delayed_objects(&self) -> Arc>> { + Arc::new(Mutex::new(HashMap::new())) + } } fn payment_key(inbound: bool, payment_hash: &[u8; 32]) -> String { diff --git a/mutiny-wasm/src/indexed_db.rs b/mutiny-wasm/src/indexed_db.rs index b9f1208e4..092af3721 100644 --- a/mutiny-wasm/src/indexed_db.rs +++ b/mutiny-wasm/src/indexed_db.rs @@ -1,6 +1,7 @@ use anyhow::anyhow; use async_trait::async_trait; use bip39::Mnemonic; +use futures::lock::Mutex; use gloo_utils::format::JsValueSerdeExt; use lightning::util::logger::Logger; use lightning::{log_debug, log_error}; @@ -43,6 +44,7 @@ pub struct IndexedDbStorage { pub(crate) indexed_db: Arc>, vss: Option>, logger: Arc, + delayed_keys: Arc>>, } impl IndexedDbStorage { @@ -73,6 +75,7 @@ impl IndexedDbStorage { indexed_db, vss, logger, + delayed_keys: Arc::new(Mutex::new(HashMap::new())), }) } @@ -786,6 +789,10 @@ impl MutinyStorage for IndexedDbStorage { } } } + + fn get_delayed_objects(&self) -> Arc>> { + self.delayed_keys.clone() + } } #[cfg(test)] From 377caae0d0c216749c13db72d73db03e91e9173c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 30 Jan 2024 04:31:01 -0600 Subject: [PATCH 06/17] Do not delete data for a deleted federation --- mutiny-core/src/federation.rs | 3 +++ mutiny-core/src/lib.rs | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index 9d992a003..c510ceb31 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -159,6 +159,7 @@ pub(crate) struct FederationClient { pub(crate) uuid: String, pub(crate) fedimint_client: ClientArc, storage: S, + #[allow(dead_code)] fedimint_storage: FedimintStorage, pub(crate) logger: Arc, } @@ -570,6 +571,8 @@ impl FederationClient { } } + // delete_fedimint_storage is not suggested at the moment due to the lack of easy restores + #[allow(dead_code)] pub async fn delete_fedimint_storage(&self) -> Result<(), MutinyError> { self.fedimint_storage.delete_store().await } diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 5f317b086..275c2c540 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -1688,7 +1688,7 @@ impl MutinyWallet { Ok(federation_identities) } - /// Removes a federation by setting its archived status to true, based on the FederationId. + /// Removes a federation by removing it from the user's federation list. pub async fn remove_federation(&self, federation_id: FederationId) -> Result<(), MutinyError> { let mut federations_guard = self.federations.write().await; @@ -1702,7 +1702,9 @@ impl MutinyWallet { self.storage .insert_federations(federation_storage_guard.clone()) .await?; - fedimint_client.delete_fedimint_storage().await?; + // TODO in the future, delete user's fedimint storage too + // for now keep it in case they restore the federation again + // fedimint_client.delete_fedimint_storage().await?; federations_guard.remove(&federation_id); } else { return Err(MutinyError::NotFound); From c8d233c9a17cf634f5b73c619a6f0296f6a19a5a Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 30 Jan 2024 05:01:14 -0600 Subject: [PATCH 07/17] Fix federation data next version number --- mutiny-core/src/lib.rs | 16 +++++----------- mutiny-core/src/storage.rs | 6 +----- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 275c2c540..152875af7 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -1699,6 +1699,7 @@ impl MutinyWallet { if federation_storage_guard.federations.contains_key(uuid) { federation_storage_guard.federations.remove(uuid); + federation_storage_guard.version += 1; self.storage .insert_federations(federation_storage_guard.clone()) .await?; @@ -2036,11 +2037,8 @@ pub(crate) async fn create_new_federation( // be saved. let mut federation_mutex = federation_storage.write().await; - // Get the current federations so that we can check if the new federation already exists - let mut existing_federations = storage.get_federations()?; - // Check if the federation already exists - if existing_federations + if federation_mutex .federations .values() .any(|federation| federation.federation_code == federation_code) @@ -2054,15 +2052,11 @@ pub(crate) async fn create_new_federation( federation_code: federation_code.clone(), }; - existing_federations.version += 1; - existing_federations + federation_mutex .federations .insert(next_federation_uuid.clone(), next_federation.clone()); - - storage - .insert_federations(existing_federations.clone()) - .await?; - federation_mutex.federations = existing_federations.federations.clone(); + federation_mutex.version += 1; + storage.insert_federations(federation_mutex.clone()).await?; // now create the federation process and init it let new_federation = FederationClient::new( diff --git a/mutiny-core/src/storage.rs b/mutiny-core/src/storage.rs index 0574aeab6..21773966f 100644 --- a/mutiny-core/src/storage.rs +++ b/mutiny-core/src/storage.rs @@ -453,11 +453,7 @@ pub trait MutinyStorage: Clone + Sized + Send + Sync + 'static { } /// Inserts the federation indexes into storage - async fn insert_federations( - &self, - mut federations: FederationStorage, - ) -> Result<(), MutinyError> { - federations.version += 1; + async fn insert_federations(&self, federations: FederationStorage) -> Result<(), MutinyError> { let version = Some(federations.version); self.set_data_async(FEDERATIONS_KEY.to_string(), federations, version) .await From c1139141f332e8e77d5964b801a58b187a10976e Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 30 Jan 2024 05:34:55 -0600 Subject: [PATCH 08/17] Create invoice from lightning node if amt is high --- mutiny-core/src/lib.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 152875af7..be3b10f79 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -103,6 +103,7 @@ use crate::utils::parse_profile_metadata; use mockall::{automock, predicate::*}; const DEFAULT_PAYMENT_TIMEOUT: u64 = 30; +const MAX_FEDERATION_INVOICE_AMT: u64 = 200_000; #[cfg_attr(test, automock)] pub trait InvoiceHandler { @@ -1126,18 +1127,16 @@ impl MutinyWallet { amount: Option, labels: Vec, ) -> Result { - let federation_ids = self.list_federation_ids().await?; + let amt = amount.map_or(Err(MutinyError::InvalidArgumentsError), Ok)?; - // Attempt to create federation invoice - if !federation_ids.is_empty() { + // Attempt to create federation invoice if available and below max amount + let federation_ids = self.list_federation_ids().await?; + if !federation_ids.is_empty() && amt <= MAX_FEDERATION_INVOICE_AMT { let federation_id = &federation_ids[0]; let fedimint_client = self.federations.read().await.get(federation_id).cloned(); if let Some(client) = fedimint_client { - if let Ok(inv) = client - .get_invoice(amount.unwrap_or_default(), labels.clone()) - .await - { + if let Ok(inv) = client.get_invoice(amt, labels.clone()).await { self.storage .set_invoice_labels(inv.bolt11.clone().expect("just created"), labels)?; return Ok(inv); @@ -1146,10 +1145,9 @@ impl MutinyWallet { } // Fallback to node_manager invoice creation if no federation invoice created - let inv = self.node_manager.create_invoice(amount).await?; + let inv = self.node_manager.create_invoice(Some(amt)).await?; self.storage .set_invoice_labels(inv.bolt11.clone().expect("just created"), labels)?; - Ok(inv) } From 946f8fec5df228c70502a7faf8b9b6b721aea20f Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Wed, 31 Jan 2024 06:22:10 -0600 Subject: [PATCH 09/17] Make amount required for invoices --- mutiny-core/src/lib.rs | 20 +++++++++----------- mutiny-core/src/node.rs | 15 ++++++--------- mutiny-core/src/nodemanager.rs | 3 +-- mutiny-core/src/nostr/mod.rs | 2 +- mutiny-wasm/src/lib.rs | 2 +- 5 files changed, 18 insertions(+), 24 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index be3b10f79..988261f12 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -118,7 +118,7 @@ pub trait InvoiceHandler { ) -> Result; async fn create_invoice( &self, - amount: Option, + amount: u64, labels: Vec, ) -> Result; } @@ -1099,11 +1099,11 @@ impl MutinyWallet { amount: Option, labels: Vec, ) -> Result { - let invoice = if self.safe_mode { + let invoice = if self.safe_mode || amount.is_none() { None } else { Some( - self.create_lightning_invoice(amount, labels.clone()) + self.create_lightning_invoice(amount.expect("just checked"), labels.clone()) .await? .bolt11 .ok_or(MutinyError::InvoiceCreationFailed)?, @@ -1124,19 +1124,17 @@ impl MutinyWallet { async fn create_lightning_invoice( &self, - amount: Option, + amount: u64, labels: Vec, ) -> Result { - let amt = amount.map_or(Err(MutinyError::InvalidArgumentsError), Ok)?; - // Attempt to create federation invoice if available and below max amount let federation_ids = self.list_federation_ids().await?; - if !federation_ids.is_empty() && amt <= MAX_FEDERATION_INVOICE_AMT { + if !federation_ids.is_empty() && amount <= MAX_FEDERATION_INVOICE_AMT { let federation_id = &federation_ids[0]; let fedimint_client = self.federations.read().await.get(federation_id).cloned(); if let Some(client) = fedimint_client { - if let Ok(inv) = client.get_invoice(amt, labels.clone()).await { + if let Ok(inv) = client.get_invoice(amount, labels.clone()).await { self.storage .set_invoice_labels(inv.bolt11.clone().expect("just created"), labels)?; return Ok(inv); @@ -1145,7 +1143,7 @@ impl MutinyWallet { } // Fallback to node_manager invoice creation if no federation invoice created - let inv = self.node_manager.create_invoice(Some(amt)).await?; + let inv = self.node_manager.create_invoice(amount).await?; self.storage .set_invoice_labels(inv.bolt11.clone().expect("just created"), labels)?; Ok(inv) @@ -1927,7 +1925,7 @@ impl MutinyWallet { // fixme: do we need to use this description? let _description = withdraw.default_description.clone(); let mutiny_invoice = self - .create_invoice(Some(amount_sats), vec!["LNURL Withdrawal".to_string()]) + .create_invoice(amount_sats, vec!["LNURL Withdrawal".to_string()]) .await?; let invoice_str = mutiny_invoice.bolt11.expect("Invoice should have bolt11"); let res = self @@ -1985,7 +1983,7 @@ impl InvoiceHandler for MutinyWallet { async fn create_invoice( &self, - amount: Option, + amount: u64, labels: Vec, ) -> Result { self.create_lightning_invoice(amount, labels).await diff --git a/mutiny-core/src/node.rs b/mutiny-core/src/node.rs index 1b67a6c67..f8c2d2818 100644 --- a/mutiny-core/src/node.rs +++ b/mutiny-core/src/node.rs @@ -971,7 +971,7 @@ impl Node { pub async fn create_invoice( &self, - amount_sat: Option, + amount_sat: u64, route_hints: Option>, ) -> Result { match self.lsp_client.as_ref() { @@ -982,9 +982,6 @@ impl Node { ) .await?; - // LSP requires an amount: - let amount_sat = amount_sat.ok_or(MutinyError::BadAmountError)?; - // Needs any amount over 0 if channel exists // Needs amount over minimum if no channel let inbound_capacity_msat: u64 = self @@ -1112,7 +1109,7 @@ impl Node { } } None => Ok(self - .create_internal_invoice(amount_sat, None, route_hints) + .create_internal_invoice(Some(amount_sat), None, route_hints) .await?), } } @@ -2420,7 +2417,7 @@ mod tests { let amount_sats = 1_000; - let invoice = node.create_invoice(Some(amount_sats), None).await.unwrap(); + let invoice = node.create_invoice(amount_sats, None).await.unwrap(); assert_eq!(invoice.amount_milli_satoshis(), Some(amount_sats * 1000)); match invoice.description() { @@ -2451,7 +2448,7 @@ mod tests { let storage = MemoryStorage::default(); let node = create_node(storage).await; - let invoice = node.create_invoice(Some(10_000), None).await.unwrap(); + let invoice = node.create_invoice(10_000, None).await.unwrap(); let result = node .pay_invoice_with_timeout(&invoice, None, None, vec![]) @@ -2574,7 +2571,7 @@ mod wasm_test { let amount_sats = 1_000; - let invoice = node.create_invoice(Some(amount_sats), None).await.unwrap(); + let invoice = node.create_invoice(amount_sats, None).await.unwrap(); assert_eq!(invoice.amount_milli_satoshis(), Some(amount_sats * 1000)); match invoice.description() { @@ -2605,7 +2602,7 @@ mod wasm_test { let storage = MemoryStorage::default(); let node = create_node(storage).await; - let invoice = node.create_invoice(Some(10_000), None).await.unwrap(); + let invoice = node.create_invoice(10_000, None).await.unwrap(); let result = node .pay_invoice_with_timeout(&invoice, None, None, vec![]) diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index e0a15ccb1..13fab64ed 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -1365,12 +1365,11 @@ impl NodeManager { // all values in sats /// Creates a lightning invoice. The amount should be in satoshis. - /// If no amount is provided, the invoice will be created with no amount. /// If no description is provided, the invoice will be created with no description. /// /// If the manager has more than one node it will create a phantom invoice. /// If there is only one node it will create an invoice just for that node. - pub async fn create_invoice(&self, amount: Option) -> Result { + pub async fn create_invoice(&self, amount: u64) -> Result { let nodes = self.nodes.lock().await; let use_phantom = nodes.len() > 1 && self.lsp_config.is_none(); if nodes.len() == 0 { diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index e78729d45..b907ea0ea 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -996,7 +996,7 @@ impl NostrManager { client.connect().await; let invoice = invoice_handler - .create_invoice(Some(amount_sats), vec!["Gift".to_string()]) + .create_invoice(amount_sats, vec!["Gift".to_string()]) .await?; // unwrap is safe, we just created it let bolt11 = invoice.bolt11.unwrap(); diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 18d45b9d2..e5c06bdec 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -766,7 +766,7 @@ impl MutinyWallet { #[wasm_bindgen] pub async fn create_invoice( &self, - amount: Option, + amount: u64, labels: Vec, ) -> Result { Ok(self.inner.create_invoice(amount, labels).await?.into()) From 11580ecb0dc4c9c18f817417623720ef40c8efe0 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 30 Jan 2024 06:17:35 -0600 Subject: [PATCH 10/17] Remove excess on chain sweep parameters --- mutiny-core/src/nodemanager.rs | 11 +++-------- mutiny-wasm/src/lib.rs | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index 13fab64ed..7dd0411cf 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -1514,12 +1514,10 @@ impl NodeManager { /// The UTXOs must all exist in the wallet. pub async fn sweep_utxos_to_channel( &self, - user_chan_id: Option, - self_node_pubkey: Option<&PublicKey>, utxos: &[OutPoint], to_pubkey: Option, ) -> Result { - let node = self.get_node_by_key_or_first(self_node_pubkey).await?; + let node = self.get_node_by_key_or_first(None).await?; let to_pubkey = match to_pubkey { Some(pubkey) => pubkey, None => node @@ -1530,7 +1528,7 @@ impl NodeManager { }; let outpoint = node - .sweep_utxos_to_channel_with_timeout(user_chan_id, utxos, to_pubkey, 60) + .sweep_utxos_to_channel_with_timeout(None, utxos, to_pubkey, 60) .await?; let all_channels = node.channel_manager.list_channels(); @@ -1550,8 +1548,6 @@ impl NodeManager { /// The node must be online and have a connection to the peer. pub async fn sweep_all_to_channel( &self, - user_chan_id: Option, - from_node: Option<&PublicKey>, to_pubkey: Option, ) -> Result { let utxos = self @@ -1560,8 +1556,7 @@ impl NodeManager { .map(|u| u.outpoint) .collect::>(); - self.sweep_utxos_to_channel(user_chan_id, from_node, &utxos, to_pubkey) - .await + self.sweep_utxos_to_channel(&utxos, to_pubkey).await } /// Closes a channel with the given outpoint. diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index e5c06bdec..0a2bd8033 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -971,7 +971,7 @@ impl MutinyWallet { Ok(self .inner .node_manager - .sweep_all_to_channel(None, None, to_pubkey) + .sweep_all_to_channel(to_pubkey) .await? .into()) } From ce9afd1bca6bd8e0b8e425f6f8d30425588567cb Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 5 Jan 2024 16:27:35 -0600 Subject: [PATCH 11/17] Show gateway_fee in FederationIdentity --- mutiny-core/src/federation.rs | 33 ++++++++++++++++++++++++++++++--- mutiny-core/src/lib.rs | 14 +++++++++----- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index c510ceb31..a3c35e0d5 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -58,7 +58,7 @@ use hex::FromHex; use lightning::{ ln::PaymentHash, log_debug, log_error, log_info, log_trace, log_warn, util::logger::Logger, }; -use lightning_invoice::Bolt11Invoice; +use lightning_invoice::{Bolt11Invoice, RoutingFees}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{collections::HashMap, fmt::Debug, sync::Arc}; use std::{ @@ -143,6 +143,22 @@ pub struct FederationIdentity { pub federation_name: Option, pub federation_expiry_timestamp: Option, pub welcome_message: Option, + pub gateway_fees: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +pub struct GatewayFees { + pub base_msat: u32, + pub proportional_millionths: u32, +} + +impl From for GatewayFees { + fn from(val: RoutingFees) -> Self { + GatewayFees { + base_msat: val.base_msat, + proportional_millionths: val.proportional_millionths, + } + } } // This is the FederationIndex reference that is saved to the DB @@ -274,6 +290,15 @@ impl FederationClient { }) } + pub(crate) async fn gateway_fee(&self) -> Result { + let lightning_module = self + .fedimint_client + .get_first_module::(); + + let gw = lightning_module.select_active_gateway().await?; + Ok(gw.fees.into()) + } + pub(crate) async fn get_invoice( &self, amount: u64, @@ -559,7 +584,9 @@ impl FederationClient { } } - pub fn get_mutiny_federation_identity(&self) -> FederationIdentity { + pub async fn get_mutiny_federation_identity(&self) -> FederationIdentity { + let gateway_fees = self.gateway_fee().await.ok(); + FederationIdentity { uuid: self.uuid.clone(), federation_id: self.fedimint_client.federation_id(), @@ -568,6 +595,7 @@ impl FederationClient { .fedimint_client .get_meta("federation_expiry_timestamp"), welcome_message: self.fedimint_client.get_meta("welcome_message"), + gateway_fees, } } @@ -1049,7 +1077,6 @@ fn fedimint_mnemonic_generation() { fn gateway_preference() { use fedimint_core::util::SafeUrl; use fedimint_ln_common::{LightningGateway, LightningGatewayAnnouncement}; - use lightning_invoice::RoutingFees; use std::time::Duration; use super::*; diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 988261f12..764124be4 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -1667,10 +1667,11 @@ impl MutinyWallet { /// Lists the federation id's of the federation clients in the manager. pub async fn list_federations(&self) -> Result, MutinyError> { let federations = self.federations.read().await; - let federation_identities = federations - .iter() - .map(|(_, n)| n.get_mutiny_federation_identity()) - .collect(); + let mut federation_identities = Vec::new(); + for f in federations.iter() { + let i = f.1.get_mutiny_federation_identity().await; + federation_identities.push(i); + } Ok(federation_identities) } @@ -1757,7 +1758,7 @@ impl MutinyWallet { let fedimint_client = federation_lock.get(&fed_id).ok_or(MutinyError::NotFound)?; let balance = fedimint_client.get_balance().await?; - let identity = fedimint_client.get_mutiny_federation_identity(); + let identity = fedimint_client.get_mutiny_federation_identity().await; balances.push(FederationBalance { identity, balance }); } @@ -2071,6 +2072,8 @@ pub(crate) async fn create_new_federation( .fedimint_client .get_meta("federation_expiry_timestamp"); let welcome_message = new_federation.fedimint_client.get_meta("welcome_message"); + let gateway_fees = new_federation.gateway_fee().await.ok(); + federations .write() .await @@ -2082,6 +2085,7 @@ pub(crate) async fn create_new_federation( federation_name, federation_expiry_timestamp, welcome_message, + gateway_fees, }) } From 177309812e790a9903a9e9b21426f880ae8a8b80 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 30 Jan 2024 17:27:55 -0600 Subject: [PATCH 12/17] Swap fedimint balance to lightning channel --- mutiny-core/src/lib.rs | 288 ++++++++++++++++++++++++++++++++++++++++- mutiny-wasm/src/lib.rs | 5 + 2 files changed, 288 insertions(+), 5 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 764124be4..54c04afab 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -43,15 +43,18 @@ mod test_utils; pub use crate::gossip::{GOSSIP_SYNC_TIME_KEY, NETWORK_GRAPH_KEY, PROB_SCORER_KEY}; pub use crate::keymanager::generate_seed; pub use crate::ldkstorage::{CHANNEL_MANAGER_KEY, MONITORS_PREFIX_KEY}; -use crate::storage::{ - list_payment_info, MutinyStorage, DEVICE_ID_KEY, EXPECTED_NETWORK_KEY, NEED_FULL_SYNC_KEY, -}; use crate::{auth::MutinyAuthClient, logging::MutinyLogger}; use crate::{error::MutinyError, nostr::ReservedProfile}; use crate::{ event::{HTLCStatus, MillisatAmount, PaymentInfo}, onchain::FULL_SYNC_STOP_GAP, }; +use crate::{ + federation::GatewayFees, + storage::{ + list_payment_info, MutinyStorage, DEVICE_ID_KEY, EXPECTED_NETWORK_KEY, NEED_FULL_SYNC_KEY, + }, +}; use crate::{ federation::{FederationClient, FederationIdentity, FederationIndex, FederationStorage}, labels::{get_contact_key, Contact, LabelStorage}, @@ -104,6 +107,7 @@ use mockall::{automock, predicate::*}; const DEFAULT_PAYMENT_TIMEOUT: u64 = 30; const MAX_FEDERATION_INVOICE_AMT: u64 = 200_000; +const SWAP_LABEL: &str = "SWAP"; #[cfg_attr(test, automock)] pub trait InvoiceHandler { @@ -1122,6 +1126,112 @@ impl MutinyWallet { }) } + pub async fn sweep_federation_balance(&self, amount: Option) -> Result<(), MutinyError> { + // Attempt to create federation invoice if available and below max amount + let federation_ids = self.list_federation_ids().await?; + if federation_ids.is_empty() { + return Err(MutinyError::BadAmountError); + } + + // TODO support more than one federation + let federation_id = &federation_ids[0]; + let federation_lock = self.federations.read().await; + let fedimint_client = federation_lock + .get(federation_id) + .ok_or(MutinyError::NotFound)?; + + // if the user provided amount, this is easy + if let Some(amt) = amount { + let inv = self.node_manager.create_invoice(amt).await?; + self.storage.set_invoice_labels( + inv.bolt11.clone().expect("just created"), + vec![SWAP_LABEL.to_string()], + )?; + let _ = fedimint_client + .pay_invoice( + inv.bolt11.expect("create inv had one job"), + vec![SWAP_LABEL.to_string()], + ) + .await?; + return Ok(()); + } + + // If no amount, figure out the amount to send over + let current_balance = fedimint_client.get_balance().await?; + log_info!( + self.logger, + "current fedimint client balance: {}", + current_balance + ); + + let fees = fedimint_client.gateway_fee().await?; + let amt = max_spendable_amount(current_balance, &fees) + .map_or(Err(MutinyError::InsufficientBalance), Ok)?; + log_info!(self.logger, "max spendable: {}", amt); + + // try to get an invoice for this exact amount + let inv = self.node_manager.create_invoice(amt).await?; + + let inv_amt = inv.amount_sats.ok_or(MutinyError::BadAmountError)?; + let inv_to_pay = if inv_amt > amt { + let new_amt = inv_amt - (inv_amt - amt); + log_info!(self.logger, "adjusting amount to swap to: {}", amt); + self.node_manager.create_invoice(new_amt).await? + } else { + inv.clone() + }; + self.storage.set_invoice_labels( + inv_to_pay.bolt11.clone().expect("just created"), + vec![SWAP_LABEL.to_string()], + )?; + + log_info!(self.logger, "attempting payment from fedimint client"); + let _ = fedimint_client + .pay_invoice( + inv_to_pay.bolt11.expect("create inv had one job"), + vec![SWAP_LABEL.to_string()], + ) + .await?; + + // pay_invoice returns invoice if Succeeded or Err if something else + // it's safe to assume that it went through and we can check remaining balance + let remaining_balance = fedimint_client.get_balance().await?; + log_info!( + self.logger, + "remaining fedimint balance: {}", + remaining_balance + ); + if remaining_balance > 1 { + // the fee for existing channel is voltage 1 sat + base fee + ppm + let remaining_balance_minus_fee = max_spendable_amount(remaining_balance - 1, &fees); + if remaining_balance_minus_fee.is_none() { + return Ok(()); + } + let remaining_balance_minus_fee = remaining_balance_minus_fee.unwrap(); + + let inv = self + .node_manager + .create_invoice(remaining_balance_minus_fee) + .await?; + + match fedimint_client + .pay_invoice(inv.bolt11.expect("create inv had one job"), vec![]) + .await + { + Ok(_) => { + log_info!(self.logger, "paid remaining balance") + } + Err(e) => { + // Don't want to return this error since it's just "incomplete", + // and just not the full amount. + log_warn!(self.logger, "error paying remaining balance: {}", e) + } + } + } + + Ok(()) + } + async fn create_lightning_invoice( &self, amount: u64, @@ -2089,11 +2199,174 @@ pub(crate) async fn create_new_federation( }) } +// max amount that can be spent through a gateway +fn max_spendable_amount(current_balance_sat: u64, routing_fees: &GatewayFees) -> Option { + let current_balance_msat = current_balance_sat as f64 * 1_000.0; + + // proportional fee on the current balance + let prop_fee_msat = + (current_balance_msat * routing_fees.proportional_millionths as f64) / 1_000_000.0; + + // The max balance considering the maximum possible proportional fee. + // This gives us a baseline to start checking the fees from. In the case that the fee is 1% + // The real maximum balance will be somewhere between our current balance and 99% of our + // balance. + let initial_max = current_balance_msat - (routing_fees.base_msat as f64 + prop_fee_msat); + + // if the fee would make the amount go negative, then there is not a possible amount to spend + if initial_max <= 0.0 { + return None; + } + + // if the initial balance and initial maximum is basically the same, then that's it + // this is basically only ever the case if there's not really any fee involved + if current_balance_msat - initial_max < 1.0 { + return Some((initial_max / 1_000.0).floor() as u64); + } + + // keep trying until we hit our balance or find the max amount + let mut new_max = initial_max; + while new_max < current_balance_msat { + // we increment by one and check the fees for it + let new_check = new_max + 1.0; + + // this is the new proportional fee to check with + let prop_fee_sat = (new_check * routing_fees.proportional_millionths as f64) / 1_000_000.0; + + // check the new spendable balance amount plus base fees plus new proportional fee + let new_amt = new_check + routing_fees.base_msat as f64 + prop_fee_sat; + if current_balance_msat - new_amt <= 0.0 { + // since we are incrementing from a minimum spendable amount, + // if we overshot our total balance then the last max is the highest + return Some((new_max / 1_000.0).floor() as u64); + } + + // this is the new spendable maximum + new_max += 1.0; + } + + Some((new_max / 1_000.0).floor() as u64) +} + #[cfg(test)] +fn max_routing_fee_amount() { + let initial_budget = 1; + let routing_fees = GatewayFees { + base_msat: 10_000, + proportional_millionths: 0, + }; + assert_eq!(None, max_spendable_amount(initial_budget, &routing_fees)); + + // only a percentage fee + let initial_budget = 100; + let routing_fees = GatewayFees { + base_msat: 0, + proportional_millionths: 0, + }; + assert_eq!( + Some(100), + max_spendable_amount(initial_budget, &routing_fees) + ); + + let initial_budget = 100; + let routing_fees = GatewayFees { + base_msat: 0, + proportional_millionths: 10_000, + }; + assert_eq!( + Some(99), + max_spendable_amount(initial_budget, &routing_fees) + ); + + let initial_budget = 100; + let routing_fees = GatewayFees { + base_msat: 0, + proportional_millionths: 100_000, + }; + assert_eq!( + Some(90), + max_spendable_amount(initial_budget, &routing_fees) + ); + + let initial_budget = 101_000; + let routing_fees = GatewayFees { + base_msat: 0, + proportional_millionths: 100_000, + }; + assert_eq!( + Some(91_818), + max_spendable_amount(initial_budget, &routing_fees) + ); + + let initial_budget = 101; + let routing_fees = GatewayFees { + base_msat: 0, + proportional_millionths: 100_000, + }; + assert_eq!( + Some(91), + max_spendable_amount(initial_budget, &routing_fees) + ); + + // same tests but with a base fee + let initial_budget = 100; + let routing_fees = GatewayFees { + base_msat: 1_000, + proportional_millionths: 0, + }; + assert_eq!( + Some(99), + max_spendable_amount(initial_budget, &routing_fees) + ); + + let initial_budget = 100; + let routing_fees = GatewayFees { + base_msat: 1_000, + proportional_millionths: 10_000, + }; + assert_eq!( + Some(98), + max_spendable_amount(initial_budget, &routing_fees) + ); + + let initial_budget = 100; + let routing_fees = GatewayFees { + base_msat: 1_000, + proportional_millionths: 100_000, + }; + assert_eq!( + Some(89), + max_spendable_amount(initial_budget, &routing_fees) + ); + + let initial_budget = 101; + let routing_fees = GatewayFees { + base_msat: 1_000, + proportional_millionths: 100_000, + }; + assert_eq!( + Some(90), + max_spendable_amount(initial_budget, &routing_fees) + ); +} + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use super::*; + + #[test] + fn test_max_routing_fee_amount() { + max_routing_fee_amount(); + } +} + +#[cfg(test)] +#[cfg(target_arch = "wasm32")] mod tests { use crate::{ - encrypt::encryption_key_from_pass, generate_seed, nodemanager::NodeManager, MutinyWallet, - MutinyWalletBuilder, MutinyWalletConfigBuilder, + encrypt::encryption_key_from_pass, generate_seed, max_routing_fee_amount, + nodemanager::NodeManager, MutinyWallet, MutinyWalletBuilder, MutinyWalletConfigBuilder, }; use bitcoin::util::bip32::ExtendedPrivKey; use bitcoin::Network; @@ -2401,4 +2674,9 @@ mod tests { assert_eq!(next.len(), 2); assert!(next.iter().all(|m| !messages.contains(m))) } + + #[test] + fn test_max_routing_fee_amount() { + max_routing_fee_amount(); + } } diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 0a2bd8033..552cb951a 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -976,6 +976,11 @@ impl MutinyWallet { .into()) } + /// Sweep the federation balance into a lightning channel + pub async fn sweep_federation_balance(&self, amount: Option) -> Result<(), MutinyJsError> { + Ok(self.inner.sweep_federation_balance(amount).await?) + } + /// Closes a channel with the given outpoint. /// /// If force is true, the channel will be force closed. From 75c395ead4e48f054de710799b8d41f87917b896 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Wed, 31 Jan 2024 08:13:28 -0600 Subject: [PATCH 13/17] Estimates fedimint fee --- mutiny-core/src/lib.rs | 52 ++++++++++++++++++++++++++++++++++++++++++ mutiny-wasm/src/lib.rs | 20 ++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 54c04afab..253e29405 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -1072,6 +1072,58 @@ impl MutinyWallet { } } + /// Estimates the lightning fee for a transaction. Amount is either from the invoice + /// if one is available or a passed in amount (priority). It will try to predict either + /// sending the payment through a federation or through lightning, depending on balances. + /// The amount and fee is in satoshis. + /// Returns None if it has no good way to calculate fee. + pub async fn estimate_ln_fee( + &self, + inv: Option<&Bolt11Invoice>, + amt_sats: Option, + ) -> Result, MutinyError> { + let amt = amt_sats + .or(inv.and_then(|i| i.amount_milli_satoshis())) + .ok_or(MutinyError::BadAmountError)?; + + // check balances first + let total_balances = self.get_balance().await?; + if total_balances.federation > amt { + let federation_ids = self.list_federation_ids().await?; + for federation_id in federation_ids { + // Check if the federation has enough balance + if let Some(fedimint_client) = self.federations.read().await.get(&federation_id) { + let current_balance = fedimint_client.get_balance().await?; + log_info!( + self.logger, + "current fedimint client balance: {}", + current_balance + ); + + let fees = fedimint_client.gateway_fee().await?; + let max_spendable = max_spendable_amount(current_balance, &fees) + .map_or(Err(MutinyError::InsufficientBalance), Ok)?; + + if max_spendable >= amt { + let prop_fee_msat = + (amt as f64 * 1_000.0 * fees.proportional_millionths as f64) + / 1_000_000.0; + + let total_fee = fees.base_msat as f64 + prop_fee_msat; + return Ok(Some((total_fee / 1_000.0).floor() as u64)); + } + } + } + } + + if total_balances.lightning > amt { + // TODO try something to try to get lightning fee + return Ok(None); + } + + Err(MutinyError::InsufficientBalance) + } + /// Creates a BIP 21 invoice. This creates a new address and a lightning invoice. /// The lightning invoice may return errors related to the LSP. Check the error and /// fallback to `get_new_address` and warn the user that Lightning is not available. diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 552cb951a..95f9cdcdf 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -608,6 +608,26 @@ impl MutinyWallet { .estimate_sweep_channel_open_fee(fee_rate)?) } + /// Estimates the lightning fee for a transaction. Amount is either from the invoice + /// if one is available or a passed in amount (priority). It will try to predict either + /// sending the payment through a federation or through lightning, depending on balances. + /// The amount and fee is in satoshis. + /// Returns None if it has no good way to calculate fee. + pub async fn estimate_ln_fee( + &self, + invoice_str: Option, + amt_sats: Option, + ) -> Result, MutinyJsError> { + let invoice = match invoice_str { + Some(i) => Some(Bolt11Invoice::from_str(&i)?), + None => None, + }; + Ok(self + .inner + .estimate_ln_fee(invoice.as_ref(), amt_sats) + .await?) + } + /// Bumps the given transaction by replacing the given tx with a transaction at /// the new given fee rate in sats/vbyte pub async fn bump_fee(&self, txid: String, fee_rate: f32) -> Result { From 3e5788a8fb2957b293382275351fc99ec19a3c24 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 1 Feb 2024 06:02:59 -0600 Subject: [PATCH 14/17] Handle incorrect MutinyError::other message --- mutiny-wasm/src/error.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mutiny-wasm/src/error.rs b/mutiny-wasm/src/error.rs index b05e715d9..7af60598f 100644 --- a/mutiny-wasm/src/error.rs +++ b/mutiny-wasm/src/error.rs @@ -211,7 +211,12 @@ impl From for MutinyJsError { MutinyError::SamePassword => MutinyJsError::SamePassword, MutinyError::Other(e) => { error!("Got unhandled error: {e}"); - MutinyJsError::UnknownError + // FIXME: For some unknown reason, InsufficientBalance is being returned as `Other` + if e.to_string().starts_with("Insufficient balance") { + MutinyJsError::InsufficientBalance + } else { + MutinyJsError::UnknownError + } } MutinyError::SubscriptionClientNotConfigured => { MutinyJsError::SubscriptionClientNotConfigured From 2237b51f04ded385d095232945dd23c0bf594fd5 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 1 Feb 2024 06:03:20 -0600 Subject: [PATCH 15/17] Return fedimint sweep result --- mutiny-core/src/lib.rs | 67 ++++++++++++++++++++++++++++++--------- mutiny-wasm/src/lib.rs | 7 ++-- mutiny-wasm/src/models.rs | 25 +++++++++++++++ 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 253e29405..1bc6dfc34 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -412,6 +412,17 @@ impl MutinyInvoice { } } +/// FedimintSweepResult is the result of how much was swept and the fees paid. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct FedimintSweepResult { + /// The final amount that was swept. + /// This should be the amount specified if it was not max. + pub amount: u64, + + /// The total fees paid for the sweep. + pub fees: Option, +} + pub struct MutinyWalletConfigBuilder { xprivkey: ExtendedPrivKey, #[cfg(target_arch = "wasm32")] @@ -1178,7 +1189,10 @@ impl MutinyWallet { }) } - pub async fn sweep_federation_balance(&self, amount: Option) -> Result<(), MutinyError> { + pub async fn sweep_federation_balance( + &self, + amount: Option, + ) -> Result { // Attempt to create federation invoice if available and below max amount let federation_ids = self.list_federation_ids().await?; if federation_ids.is_empty() { @@ -1199,13 +1213,16 @@ impl MutinyWallet { inv.bolt11.clone().expect("just created"), vec![SWAP_LABEL.to_string()], )?; - let _ = fedimint_client + let pay_res = fedimint_client .pay_invoice( inv.bolt11.expect("create inv had one job"), vec![SWAP_LABEL.to_string()], ) .await?; - return Ok(()); + return Ok(FedimintSweepResult { + amount: amt, + fees: pay_res.fees_paid, + }); } // If no amount, figure out the amount to send over @@ -1219,16 +1236,25 @@ impl MutinyWallet { let fees = fedimint_client.gateway_fee().await?; let amt = max_spendable_amount(current_balance, &fees) .map_or(Err(MutinyError::InsufficientBalance), Ok)?; - log_info!(self.logger, "max spendable: {}", amt); + log_debug!(self.logger, "max spendable: {}", amt); // try to get an invoice for this exact amount let inv = self.node_manager.create_invoice(amt).await?; + // check if we can afford that invoice let inv_amt = inv.amount_sats.ok_or(MutinyError::BadAmountError)?; - let inv_to_pay = if inv_amt > amt { - let new_amt = inv_amt - (inv_amt - amt); - log_info!(self.logger, "adjusting amount to swap to: {}", amt); - self.node_manager.create_invoice(new_amt).await? + let first_invoice_amount = if inv_amt > amt { + log_debug!(self.logger, "adjusting amount to swap to: {}", amt); + inv_amt - (inv_amt - amt) + } else { + inv_amt + }; + + // if invoice amount changed, create a new invoice + let inv_to_pay = if first_invoice_amount != inv_amt { + self.node_manager + .create_invoice(first_invoice_amount) + .await? } else { inv.clone() }; @@ -1237,18 +1263,23 @@ impl MutinyWallet { vec![SWAP_LABEL.to_string()], )?; - log_info!(self.logger, "attempting payment from fedimint client"); - let _ = fedimint_client + log_debug!(self.logger, "attempting payment from fedimint client"); + let mut final_result = FedimintSweepResult { + amount: first_invoice_amount, + fees: None, + }; + let first_invoice_res = fedimint_client .pay_invoice( inv_to_pay.bolt11.expect("create inv had one job"), vec![SWAP_LABEL.to_string()], ) .await?; + final_result.fees = first_invoice_res.fees_paid; // pay_invoice returns invoice if Succeeded or Err if something else // it's safe to assume that it went through and we can check remaining balance let remaining_balance = fedimint_client.get_balance().await?; - log_info!( + log_debug!( self.logger, "remaining fedimint balance: {}", remaining_balance @@ -1257,7 +1288,7 @@ impl MutinyWallet { // the fee for existing channel is voltage 1 sat + base fee + ppm let remaining_balance_minus_fee = max_spendable_amount(remaining_balance - 1, &fees); if remaining_balance_minus_fee.is_none() { - return Ok(()); + return Ok(final_result); } let remaining_balance_minus_fee = remaining_balance_minus_fee.unwrap(); @@ -1270,8 +1301,14 @@ impl MutinyWallet { .pay_invoice(inv.bolt11.expect("create inv had one job"), vec![]) .await { - Ok(_) => { - log_info!(self.logger, "paid remaining balance") + Ok(r) => { + log_debug!(self.logger, "paid remaining balance"); + final_result.amount += remaining_balance_minus_fee; + final_result.fees = final_result.fees.map_or(r.fees_paid, |val| { + r.fees_paid + .map(|add_val| val + add_val) + .map_or(Some(val), |_| None) + }); } Err(e) => { // Don't want to return this error since it's just "incomplete", @@ -1281,7 +1318,7 @@ impl MutinyWallet { } } - Ok(()) + Ok(final_result) } async fn create_lightning_invoice( diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 95f9cdcdf..662094ac2 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -997,8 +997,11 @@ impl MutinyWallet { } /// Sweep the federation balance into a lightning channel - pub async fn sweep_federation_balance(&self, amount: Option) -> Result<(), MutinyJsError> { - Ok(self.inner.sweep_federation_balance(amount).await?) + pub async fn sweep_federation_balance( + &self, + amount: Option, + ) -> Result { + Ok(self.inner.sweep_federation_balance(amount).await?.into()) } /// Closes a channel with the given outpoint. diff --git a/mutiny-wasm/src/models.rs b/mutiny-wasm/src/models.rs index d47c1bbf4..a109b620b 100644 --- a/mutiny-wasm/src/models.rs +++ b/mutiny-wasm/src/models.rs @@ -1074,6 +1074,31 @@ impl From for Plan { } } +/// FedimintSweepResult is the result of how much was swept and the fees paid. +#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)] +#[wasm_bindgen] +pub struct FedimintSweepResult { + pub amount: u64, + pub fees: Option, +} + +#[wasm_bindgen] +impl FedimintSweepResult { + #[wasm_bindgen(getter)] + pub fn value(&self) -> JsValue { + JsValue::from_serde(&serde_json::to_value(self).unwrap()).unwrap() + } +} + +impl From for FedimintSweepResult { + fn from(m: mutiny_core::FedimintSweepResult) -> Self { + FedimintSweepResult { + amount: m.amount, + fees: m.fees, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[wasm_bindgen] pub enum BudgetPeriod { From 80ac3b437239717cccf0a5467012247f1d99af91 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 1 Feb 2024 06:44:55 -0600 Subject: [PATCH 16/17] Remove the fedimint remainder sweep functionality --- mutiny-core/src/lib.rs | 49 ++++++++---------------------------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 1bc6dfc34..9aaa5a487 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -1227,7 +1227,7 @@ impl MutinyWallet { // If no amount, figure out the amount to send over let current_balance = fedimint_client.get_balance().await?; - log_info!( + log_debug!( self.logger, "current fedimint client balance: {}", current_balance @@ -1276,46 +1276,15 @@ impl MutinyWallet { .await?; final_result.fees = first_invoice_res.fees_paid; - // pay_invoice returns invoice if Succeeded or Err if something else - // it's safe to assume that it went through and we can check remaining balance let remaining_balance = fedimint_client.get_balance().await?; - log_debug!( - self.logger, - "remaining fedimint balance: {}", - remaining_balance - ); - if remaining_balance > 1 { - // the fee for existing channel is voltage 1 sat + base fee + ppm - let remaining_balance_minus_fee = max_spendable_amount(remaining_balance - 1, &fees); - if remaining_balance_minus_fee.is_none() { - return Ok(final_result); - } - let remaining_balance_minus_fee = remaining_balance_minus_fee.unwrap(); - - let inv = self - .node_manager - .create_invoice(remaining_balance_minus_fee) - .await?; - - match fedimint_client - .pay_invoice(inv.bolt11.expect("create inv had one job"), vec![]) - .await - { - Ok(r) => { - log_debug!(self.logger, "paid remaining balance"); - final_result.amount += remaining_balance_minus_fee; - final_result.fees = final_result.fees.map_or(r.fees_paid, |val| { - r.fees_paid - .map(|add_val| val + add_val) - .map_or(Some(val), |_| None) - }); - } - Err(e) => { - // Don't want to return this error since it's just "incomplete", - // and just not the full amount. - log_warn!(self.logger, "error paying remaining balance: {}", e) - } - } + if remaining_balance > 0 { + // there was a remainder when there shouldn't have been + // for now just log this, it is probably just a millisat/1 sat difference + log_warn!( + self.logger, + "remaining fedimint balance: {}", + remaining_balance + ); } Ok(final_result) From 274756485434982dca39443501957b5b21c235a6 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 1 Feb 2024 11:11:37 -0600 Subject: [PATCH 17/17] Get estimated fees for channel opens from fedimint --- mutiny-core/src/lib.rs | 73 +++++++++++++++++++++++------- mutiny-core/src/node.rs | 82 ++++++++++++++++++++++++++++------ mutiny-core/src/nodemanager.rs | 12 ++++- mutiny-wasm/src/lib.rs | 8 ++++ 4 files changed, 144 insertions(+), 31 deletions(-) diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 9aaa5a487..11ac3b976 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -1193,13 +1193,11 @@ impl MutinyWallet { &self, amount: Option, ) -> Result { - // Attempt to create federation invoice if available and below max amount + // TODO support more than one federation let federation_ids = self.list_federation_ids().await?; if federation_ids.is_empty() { - return Err(MutinyError::BadAmountError); + return Err(MutinyError::NotFound); } - - // TODO support more than one federation let federation_id = &federation_ids[0]; let federation_lock = self.federations.read().await; let fedimint_client = federation_lock @@ -1208,7 +1206,7 @@ impl MutinyWallet { // if the user provided amount, this is easy if let Some(amt) = amount { - let inv = self.node_manager.create_invoice(amt).await?; + let (inv, fee) = self.node_manager.create_invoice(amt).await?; self.storage.set_invoice_labels( inv.bolt11.clone().expect("just created"), vec![SWAP_LABEL.to_string()], @@ -1219,9 +1217,11 @@ impl MutinyWallet { vec![SWAP_LABEL.to_string()], ) .await?; + let total_fees_paid = pay_res.fees_paid.unwrap_or(0) + fee; + return Ok(FedimintSweepResult { amount: amt, - fees: pay_res.fees_paid, + fees: Some(total_fees_paid), }); } @@ -1239,7 +1239,7 @@ impl MutinyWallet { log_debug!(self.logger, "max spendable: {}", amt); // try to get an invoice for this exact amount - let inv = self.node_manager.create_invoice(amt).await?; + let (inv, fee) = self.node_manager.create_invoice(amt).await?; // check if we can afford that invoice let inv_amt = inv.amount_sats.ok_or(MutinyError::BadAmountError)?; @@ -1251,12 +1251,12 @@ impl MutinyWallet { }; // if invoice amount changed, create a new invoice - let inv_to_pay = if first_invoice_amount != inv_amt { + let (inv_to_pay, fee) = if first_invoice_amount != inv_amt { self.node_manager .create_invoice(first_invoice_amount) .await? } else { - inv.clone() + (inv.clone(), fee) }; self.storage.set_invoice_labels( inv_to_pay.bolt11.clone().expect("just created"), @@ -1264,17 +1264,12 @@ impl MutinyWallet { )?; log_debug!(self.logger, "attempting payment from fedimint client"); - let mut final_result = FedimintSweepResult { - amount: first_invoice_amount, - fees: None, - }; let first_invoice_res = fedimint_client .pay_invoice( inv_to_pay.bolt11.expect("create inv had one job"), vec![SWAP_LABEL.to_string()], ) .await?; - final_result.fees = first_invoice_res.fees_paid; let remaining_balance = fedimint_client.get_balance().await?; if remaining_balance > 0 { @@ -1287,7 +1282,53 @@ impl MutinyWallet { ); } - Ok(final_result) + Ok(FedimintSweepResult { + amount: first_invoice_amount, + fees: Some(first_invoice_res.fees_paid.unwrap_or(0) + fee), + }) + } + + /// Estimate the fee before trying to sweep from federation + pub async fn estimate_sweep_federation_fee( + &self, + amount: Option, + ) -> Result, MutinyError> { + if let Some(0) = amount { + return Ok(None); + } + + // TODO support more than one federation + let federation_ids = self.list_federation_ids().await?; + if federation_ids.is_empty() { + return Err(MutinyError::NotFound); + } + + let federation_id = &federation_ids[0]; + let federation_lock = self.federations.read().await; + let fedimint_client = federation_lock + .get(federation_id) + .ok_or(MutinyError::NotFound)?; + + // if the user provided amount, this is easy + if let Some(amt) = amount { + return Ok(Some(self.node_manager.get_lsp_fee(amt).await?)); + } + + // If no amount, figure out the amount to send over + let current_balance = fedimint_client.get_balance().await?; + log_debug!( + self.logger, + "current fedimint client balance: {}", + current_balance + ); + + let fees = fedimint_client.gateway_fee().await?; + let amt = max_spendable_amount(current_balance, &fees) + .map_or(Err(MutinyError::InsufficientBalance), Ok)?; + log_debug!(self.logger, "max spendable: {}", amt); + + // try to get an invoice for this exact amount + Ok(Some(self.node_manager.get_lsp_fee(amt).await?)) } async fn create_lightning_invoice( @@ -1311,7 +1352,7 @@ impl MutinyWallet { } // Fallback to node_manager invoice creation if no federation invoice created - let inv = self.node_manager.create_invoice(amount).await?; + let (inv, _fee) = self.node_manager.create_invoice(amount).await?; self.storage .set_invoice_labels(inv.bolt11.clone().expect("just created"), labels)?; Ok(inv) diff --git a/mutiny-core/src/node.rs b/mutiny-core/src/node.rs index f8c2d2818..876fb289a 100644 --- a/mutiny-core/src/node.rs +++ b/mutiny-core/src/node.rs @@ -968,12 +968,64 @@ impl Node { pub fn get_phantom_route_hint(&self) -> PhantomRouteHints { self.channel_manager.get_phantom_route_hints() } + pub async fn get_lsp_fee(&self, amount_sat: u64) -> Result { + match self.lsp_client.as_ref() { + Some(lsp) => { + self.connect_peer( + PubkeyConnectionInfo::new(&lsp.get_lsp_connection_string())?, + None, + ) + .await?; + + // Needs any amount over 0 if channel exists + // Needs amount over minimum if no channel + let inbound_capacity_msat: u64 = self + .channel_manager + .list_channels_with_counterparty(&lsp.get_lsp_pubkey()) + .iter() + .map(|c| c.inbound_capacity_msat) + .sum(); + + log_debug!(self.logger, "Current inbound liquidity {inbound_capacity_msat}msats, creating invoice for {}msats", amount_sat * 1000); + + let has_inbound_capacity = inbound_capacity_msat > amount_sat * 1_000; + + let min_amount_sat = if has_inbound_capacity { + 1 + } else { + utils::min_lightning_amount(self.network) + }; + + if amount_sat < min_amount_sat { + return Err(MutinyError::BadAmountError); + } + + let user_channel_id = match lsp { + AnyLsp::VoltageFlow(_) => None, + AnyLsp::Lsps(_) => Some(utils::now().as_secs().into()), + }; + + // check the fee from the LSP + let lsp_fee = lsp + .get_lsp_fee_msat(FeeRequest { + pubkey: self.pubkey.to_hex(), + amount_msat: amount_sat * 1000, + user_channel_id, + }) + .await?; + + // Convert the fee from msat to sat for comparison and subtraction + Ok(lsp_fee.fee_amount_msat / 1000) + } + None => Ok(0), + } + } pub async fn create_invoice( &self, amount_sat: u64, route_hints: Option>, - ) -> Result { + ) -> Result<(Bolt11Invoice, u64), MutinyError> { match self.lsp_client.as_ref() { Some(lsp) => { self.connect_peer( @@ -1072,13 +1124,15 @@ impl Node { log_debug!(self.logger, "Got wrapped invoice from LSP: {lsp_invoice}"); - Ok(lsp_invoice) + Ok((lsp_invoice, lsp_fee_sat)) } AnyLsp::Lsps(client) => { if has_inbound_capacity { - Ok(self - .create_internal_invoice(Some(amount_sat), None, route_hints) - .await?) + Ok(( + self.create_internal_invoice(Some(amount_sat), None, route_hints) + .await?, + 0, + )) } else { let lsp_invoice = match client .get_lsp_invoice(InvoiceRequest { @@ -1103,14 +1157,16 @@ impl Node { return Err(e); } }; - Ok(lsp_invoice) + Ok((lsp_invoice, 0)) } } } } - None => Ok(self - .create_internal_invoice(Some(amount_sat), None, route_hints) - .await?), + None => Ok(( + self.create_internal_invoice(Some(amount_sat), None, route_hints) + .await?, + 0, + )), } } @@ -2417,7 +2473,7 @@ mod tests { let amount_sats = 1_000; - let invoice = node.create_invoice(amount_sats, None).await.unwrap(); + let invoice = node.create_invoice(amount_sats, None).await.unwrap().0; assert_eq!(invoice.amount_milli_satoshis(), Some(amount_sats * 1000)); match invoice.description() { @@ -2448,7 +2504,7 @@ mod tests { let storage = MemoryStorage::default(); let node = create_node(storage).await; - let invoice = node.create_invoice(10_000, None).await.unwrap(); + let invoice = node.create_invoice(10_000, None).await.unwrap().0; let result = node .pay_invoice_with_timeout(&invoice, None, None, vec![]) @@ -2571,7 +2627,7 @@ mod wasm_test { let amount_sats = 1_000; - let invoice = node.create_invoice(amount_sats, None).await.unwrap(); + let invoice = node.create_invoice(amount_sats, None).await.unwrap().0; assert_eq!(invoice.amount_milli_satoshis(), Some(amount_sats * 1000)); match invoice.description() { @@ -2602,7 +2658,7 @@ mod wasm_test { let storage = MemoryStorage::default(); let node = create_node(storage).await; - let invoice = node.create_invoice(10_000, None).await.unwrap(); + let invoice = node.create_invoice(10_000, None).await.unwrap().0; let result = node .pay_invoice_with_timeout(&invoice, None, None, vec![]) diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index 7dd0411cf..b8db0942c 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -1369,7 +1369,7 @@ impl NodeManager { /// /// If the manager has more than one node it will create a phantom invoice. /// If there is only one node it will create an invoice just for that node. - pub async fn create_invoice(&self, amount: u64) -> Result { + pub async fn create_invoice(&self, amount: u64) -> Result<(MutinyInvoice, u64), MutinyError> { let nodes = self.nodes.lock().await; let use_phantom = nodes.len() > 1 && self.lsp_config.is_none(); if nodes.len() == 0 { @@ -1394,7 +1394,15 @@ impl NodeManager { }; let invoice = first_node.create_invoice(amount, route_hints).await?; - Ok(invoice.into()) + Ok((invoice.0.into(), invoice.1)) + } + + /// Gets the LSP fee for receiving an invoice down the first node that exists. + /// This could include the fee if a channel open is necessary. Otherwise the fee + /// will be low or non-existant. + pub async fn get_lsp_fee(&self, amount: u64) -> Result { + let node = self.get_node_by_key_or_first(None).await?; + node.get_lsp_fee(amount).await } /// Pays a lightning invoice from either a specified node or the first available node. diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 662094ac2..bcf5cbfa3 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -1004,6 +1004,14 @@ impl MutinyWallet { Ok(self.inner.sweep_federation_balance(amount).await?.into()) } + /// Estimate the fee before trying to sweep from federation + pub async fn estimate_sweep_federation_fee( + &self, + amount: Option, + ) -> Result, MutinyJsError> { + Ok(self.inner.estimate_sweep_federation_fee(amount).await?) + } + /// Closes a channel with the given outpoint. /// /// If force is true, the channel will be force closed.