diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index a40dfc4ab..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::{ @@ -1077,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 790459fc7..f8aacdeed 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}, @@ -1122,6 +1125,94 @@ 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(Some(amt)).await?; + let _ = fedimint_client + .pay_invoice(inv.bolt11.expect("create inv had one job"), vec![]) + .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(Some(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(Some(new_amt)).await? + } else { + inv.clone() + }; + + 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![]) + .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 != 0 { + // the fee for existing channel is voltage 1 sat + base fee + ppm + let remaining_balance_minus_fee = remaining_balance - 1; // Voltage is 1 sat fee + + let inv = self + .node_manager + .create_invoice(Some(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: Option, @@ -2091,11 +2182,139 @@ pub(crate) async fn create_new_federation( }) } +// max amount that can be spent through a gateway +fn max_spendable_amount(amount_sat: u64, routing_fees: GatewayFees) -> Option { + let amount_msat = amount_sat as f64 * 1_000.0; + + let prop_fee_msat = (amount_msat * routing_fees.proportional_millionths as f64) / 1_000_000.0; + + let initial_max = amount_msat - (routing_fees.base_msat as f64 + prop_fee_msat); + + if initial_max <= 0.0 { + return None; + } + + if amount_msat - initial_max < 1.0 { + return Some((initial_max / 1_000.0).floor() as u64); + } + + let mut new_max = initial_max; + while new_max < amount_msat { + let new_check = new_max + 1.0; + + let prop_fee_sat = (new_check * routing_fees.proportional_millionths as f64) / 1_000_000.0; + + let new_amt = new_check + routing_fees.base_msat as f64 + prop_fee_sat; + + if amount_msat - new_amt <= 0.0 { + // overshot it + return Some((new_max / 1_000.0).floor() as u64); + } + + 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; @@ -2403,4 +2622,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 72452fe00..1de79df9c 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.