diff --git a/src/daemon/api/balance.rs b/src/daemon/api/balance.rs index 3349460..8ded8e8 100644 --- a/src/daemon/api/balance.rs +++ b/src/daemon/api/balance.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use axum::Json; -use ldk_node::Node; +use mdk::client::MdkClient; use crate::daemon::api::error::AppError; use crate::daemon::types::GetBalanceResponse; @@ -20,7 +20,14 @@ use crate::daemon::types::GetBalanceResponse; /// can be zero even when the channel has a real outbound balance. /// /// `onchain_balance_sat` is what the user can actually sweep/send on-chain right now. -pub async fn handle_get_balance(node: Arc) -> Result, AppError> { +/// +/// `max_withdrawable_sat` is what `balance_sat` can pay out after subtracting +/// a routing-fee buffer (see [`mdk::max_sendable`]). `None` when no usable +/// LSP channel exists. +pub async fn handle_get_balance( + client: Arc, +) -> Result, AppError> { + let node = client.node(); let balances = node.list_balances(); let lightning_sat: u64 = node .list_channels() @@ -28,8 +35,11 @@ pub async fn handle_get_balance(node: Arc) -> Result) -> Result security(("basic_auth" = [])) )] async fn get_balance(State(state): State) -> Result, AppError> { - balance::handle_get_balance(state.node).await + balance::handle_get_balance(state.mdk_client).await } #[utoipa::path( diff --git a/src/daemon/config.rs b/src/daemon/config.rs index f4b06e6..0b5da5d 100644 --- a/src/daemon/config.rs +++ b/src/daemon/config.rs @@ -8,6 +8,7 @@ use ldk_node::bitcoin::Network; use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_node::lightning::routing::gossip::NodeAlias; use log::LevelFilter; +use mdk::max_sendable::MaxSendableConfig; use mdk::node::ScoringOverrides; use serde::Deserialize; @@ -19,6 +20,7 @@ struct TomlConfig { storage: Option, log: Option, splice: Option, + max_sendable: Option, } #[derive(Deserialize)] @@ -90,6 +92,12 @@ struct SpliceSection { poll_interval_secs: Option, } +#[derive(Deserialize)] +struct MaxSendableSection { + fee_buffer_bps: Option, + fee_buffer_floor_sats: Option, +} + pub struct MdkConfig { pub network: Network, pub listening_addrs: Option>, @@ -101,6 +109,7 @@ pub struct MdkConfig { pub pathfinding_scores_source_url: Option, pub scoring_overrides: ScoringOverrides, pub splice: SpliceConfig, + pub max_sendable: MaxSendableConfig, } pub fn load_config(path: &str) -> io::Result { @@ -173,6 +182,19 @@ pub fn load_config(path: &str) -> io::Result { None => SpliceConfig::default(), }; + let max_sendable = match toml.max_sendable { + Some(s) => { + let defaults = MaxSendableConfig::default(); + MaxSendableConfig { + fee_buffer_bps: s.fee_buffer_bps.unwrap_or(defaults.fee_buffer_bps), + fee_buffer_floor_sats: s + .fee_buffer_floor_sats + .unwrap_or(defaults.fee_buffer_floor_sats), + } + } + None => MaxSendableConfig::default(), + }; + Ok(MdkConfig { network, listening_addrs, @@ -184,6 +206,7 @@ pub fn load_config(path: &str) -> io::Result { pathfinding_scores_source_url: node.pathfinding_scores_source_url, scoring_overrides, splice, + max_sendable, }) } diff --git a/src/daemon/types.rs b/src/daemon/types.rs index f3020ee..e54c4e9 100644 --- a/src/daemon/types.rs +++ b/src/daemon/types.rs @@ -152,6 +152,12 @@ pub struct GetBalanceResponse { pub balance_sat: u64, /// Spendable on-chain balance in sats. pub onchain_balance_sat: u64, + /// Best-effort max sendable over Lightning right now (sats), with + /// routing-fee headroom subtracted. `null` when no usable LSP + /// channel exists. `Some(0)` is distinct from `null`: it means a + /// channel exists but the balance is fully consumed by the fee + /// buffer (dust). + pub max_withdrawable_sat: Option, } #[derive(Serialize, ToSchema)] diff --git a/src/lib.rs b/src/lib.rs index d9ae4aa..db99f3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod mdk; pub use mdk::client; pub use mdk::config; pub use mdk::error; +pub use mdk::max_sendable; pub use mdk::mdk_api; pub use mdk::node; pub use mdk::types; diff --git a/src/main.rs b/src/main.rs index 7d74414..1673453 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,6 +133,7 @@ fn main() { infra, scoring_overrides: config_file.scoring_overrides, splice: config_file.splice, + max_sendable: config_file.max_sendable, }; // Separate HTTP client for daemon concerns (webhooks, expiry monitor). diff --git a/src/mdk/client.rs b/src/mdk/client.rs index a74b816..23d95f0 100644 --- a/src/mdk/client.rs +++ b/src/mdk/client.rs @@ -15,6 +15,9 @@ use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use crate::mdk::error::{MdkError, SpliceError}; +use crate::mdk::max_sendable::{ + compute_estimate, ChannelSnapshot, MaxSendableConfig, MaxSendableError, MaxSendableEstimate, +}; use crate::mdk::mdk_api::client::MdkApiClient; use crate::mdk::mdk_api::types::{ CheckoutCustomer, CreateCheckoutRequest, PaymentEntry, PaymentReceivedRequest, @@ -37,6 +40,7 @@ pub struct MdkClient { api: Arc, lsp_pubkey: PublicKey, splice_cfg: SpliceConfig, + max_sendable_cfg: MaxSendableConfig, event_tx: broadcast::Sender, event_handler: Option, shutdown: CancellationToken, @@ -77,6 +81,7 @@ impl MdkClient { let lsp_pubkey = PublicKey::from_str(&config.infra.lsp_node_id) .map_err(|e| MdkError::InvalidInput(format!("bad lsp_node_id: {e}")))?; let splice_cfg = config.splice.clone(); + let max_sendable_cfg = config.max_sendable.clone(); let node = build_node(config, handle.clone())?; let http_client = build_http_client(socks_proxy.as_deref())?; @@ -92,6 +97,7 @@ impl MdkClient { api, lsp_pubkey, splice_cfg, + max_sendable_cfg, event_tx, event_handler, shutdown: CancellationToken::new(), @@ -137,6 +143,20 @@ impl MdkClient { self.lsp_pubkey } + /// Best-effort estimate of the largest amount that can flow out + /// over Lightning right now, with routing-fee headroom subtracted. + /// Computed inline from `node.list_channels()` on every call so + /// the result reflects in-flight HTLCs and reserve as of *now*. + pub fn max_sendable(&self) -> Result { + let snaps: Vec = self + .node + .list_channels() + .iter() + .map(ChannelSnapshot::from) + .collect(); + compute_estimate(&snaps, &self.lsp_pubkey, &self.max_sendable_cfg) + } + /// Splice `amount_sats` of confirmed on-chain funds into the /// existing channel identified by `user_channel_id`, with the /// LSP as counterparty. diff --git a/src/mdk/max_sendable.rs b/src/mdk/max_sendable.rs new file mode 100644 index 0000000..0d2ef87 --- /dev/null +++ b/src/mdk/max_sendable.rs @@ -0,0 +1,269 @@ +//! Estimator for the largest amount that can be sent over Lightning +//! out of mdk's LSP channel(s), with routing fees subtracted. +//! +//! v0 is destination-agnostic: it subtracts a configurable percentage +//! buffer (default 1%, 10-sat floor) from the sum of usable LSP +//! channels' `next_outbound_htlc_limit_msat`. v1 will replace the +//! buffer with a real `Router::find_route` + per-hop fee inversion. +//! The [`compute_estimate`] function is the seam — the accessor that +//! calls it stays put across v0→v1. + +use std::time::Instant; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::ChannelDetails; + +/// User-tunable buffer applied to the raw outbound liquidity to +/// reserve headroom for routing fees. +#[derive(Debug, Clone)] +pub struct MaxSendableConfig { + /// Percentage buffer in basis points (1 bps = 0.01 %). Default: 100 (1 %). + pub fee_buffer_bps: u16, + /// Absolute lower bound on the buffer, in sats. Default: 10. + /// Whichever of the percentage and the floor is larger wins — + /// keeps small-balance estimates honest about base fees. + pub fee_buffer_floor_sats: u64, +} + +impl Default for MaxSendableConfig { + fn default() -> Self { + Self { + fee_buffer_bps: 100, + fee_buffer_floor_sats: 10, + } + } +} + +/// A best-effort estimate of how much can flow out over Lightning +/// right now, alongside the fee headroom the estimate carved out. +#[derive(Debug, Clone)] +pub struct MaxSendableEstimate { + /// Amount to surface to the payer as "max sendable", in msat. + /// Zero when the balance is fully consumed by the buffer (dust). + pub amount_msat: u64, + /// The buffer subtracted from `balance_at_compute_msat` to reach + /// `amount_msat`. Doubles as a hint for `max_total_routing_fee_msat`. + pub fee_budget_msat: u64, + /// Raw outbound liquidity at compute time (sum of usable LSP + /// channels' `next_outbound_htlc_limit_msat`). + pub balance_at_compute_msat: u64, + /// Wall-clock-free monotonic timestamp of when the estimate was computed. + pub computed_at: Instant, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum MaxSendableError { + /// No usable LSP channel exists yet — the node is still booting, + /// the channel is opening, or it was force-closed. Distinct from + /// "balance is dust" (which returns `Ok(amount_msat: 0)`). + NoUsableChannel, +} + +/// Minimal projection of `ldk_node::ChannelDetails` carrying only the +/// fields [`compute_estimate`] looks at. +#[derive(Debug, Clone)] +pub(crate) struct ChannelSnapshot { + pub counterparty: PublicKey, + pub is_usable: bool, + pub next_outbound_htlc_limit_msat: u64, +} + +impl From<&ChannelDetails> for ChannelSnapshot { + fn from(c: &ChannelDetails) -> Self { + Self { + counterparty: c.counterparty_node_id, + is_usable: c.is_usable, + next_outbound_htlc_limit_msat: c.next_outbound_htlc_limit_msat, + } + } +} + +/// Pure compute: given a snapshot of channels, the LSP pubkey, and a +/// buffer config, return the estimate. +/// +/// ```text +/// balance_msat = Σ next_outbound_htlc_limit_msat over usable LSP channels +/// buffer_msat = max(balance_msat × buffer_bps / 10_000, buffer_floor_sats × 1000) +/// amount_msat = balance_msat.saturating_sub(buffer_msat) +/// fee_budget_msat = buffer_msat +/// ``` +/// +/// `Err(NoUsableChannel)` is returned only when no channel matches +/// `counterparty == lsp_pubkey && is_usable`. A dust-level balance +/// where the buffer eats everything yields `Ok(amount_msat: 0)` — the +/// UI distinguishes "0 sats sendable" from "no channel yet". +pub(crate) fn compute_estimate( + channels: &[ChannelSnapshot], + lsp_pubkey: &PublicKey, + cfg: &MaxSendableConfig, +) -> Result { + // `Option` accumulator distinguishes "no channel matched" + // (None → NoUsableChannel) from "channel(s) matched, sum is 0" + // (Some(0) → Ok with dust semantics). + let balance_msat = channels + .iter() + .filter(|c| c.counterparty == *lsp_pubkey && c.is_usable) + .fold(None::, |acc, c| { + Some( + acc.unwrap_or(0) + .saturating_add(c.next_outbound_htlc_limit_msat), + ) + }) + .ok_or(MaxSendableError::NoUsableChannel)?; + + // u128 intermediate dodges overflow at the percentage step. ppm + // basis-points × u64 msat fits in u128 trivially, and the divide + // brings it back into u64 range. + let pct_buffer = ((balance_msat as u128) * (cfg.fee_buffer_bps as u128) / 10_000) as u64; + let floor_buffer = cfg.fee_buffer_floor_sats.saturating_mul(1_000); + let buffer_msat = pct_buffer.max(floor_buffer); + + Ok(MaxSendableEstimate { + amount_msat: balance_msat.saturating_sub(buffer_msat), + fee_budget_msat: buffer_msat, + balance_at_compute_msat: balance_msat, + computed_at: Instant::now(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn lsp() -> PublicKey { + PublicKey::from_str("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .unwrap() + } + + fn other_peer() -> PublicKey { + PublicKey::from_str("02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") + .unwrap() + } + + fn snap(counterparty: PublicKey, is_usable: bool, limit_msat: u64) -> ChannelSnapshot { + ChannelSnapshot { + counterparty, + is_usable, + next_outbound_htlc_limit_msat: limit_msat, + } + } + + #[test] + fn no_usable_channel_when_empty() { + let lsp = lsp(); + let res = compute_estimate(&[], &lsp, &MaxSendableConfig::default()); + assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); + } + + #[test] + fn no_usable_channel_when_only_other_counterparty() { + let lsp = lsp(); + let chans = [snap(other_peer(), true, 100_000_000)]; + let res = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()); + assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); + } + + #[test] + fn no_usable_channel_when_lsp_channel_unusable() { + // Channel exists with the LSP but is mid-open or mid-splice + // — explicitly distinct from "balance is zero". + let lsp = lsp(); + let chans = [snap(lsp, false, 100_000_000)]; + let res = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()); + assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); + } + + #[test] + fn dust_balance_below_floor_returns_zero() { + // 5 sats of outbound. Floor buffer is 10 sats → buffer wins, + // amount saturates to zero. The estimate is "you have + // liquidity, but it can't cover even the floor fee" — not an + // error. + let lsp = lsp(); + let chans = [snap(lsp, true, 5_000)]; // 5 sats + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.amount_msat, 0); + assert_eq!(est.fee_budget_msat, 10_000); // 10-sat floor + assert_eq!(est.balance_at_compute_msat, 5_000); + } + + #[test] + fn balance_exactly_equals_buffer_returns_zero() { + // 10 sats balance, 10 sat floor → amount = 0 exactly, + // fee_budget = 10_000 msat. + let lsp = lsp(); + let chans = [snap(lsp, true, 10_000)]; + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.amount_msat, 0); + assert_eq!(est.fee_budget_msat, 10_000); + } + + #[test] + fn normal_case_percentage_buffer_dominates() { + // 100k sats × 1% = 1000 sats > 10-sat floor → percentage wins. + let lsp = lsp(); + let chans = [snap(lsp, true, 100_000_000)]; // 100k sats + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.fee_budget_msat, 1_000_000); // 1000 sats + assert_eq!(est.amount_msat, 99_000_000); // 99k sats + assert_eq!(est.balance_at_compute_msat, 100_000_000); + } + + #[test] + fn normal_case_floor_buffer_dominates() { + // 500 sats × 1% = 5 sats < 10-sat floor → floor wins. + let lsp = lsp(); + let chans = [snap(lsp, true, 500_000)]; // 500 sats + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.fee_budget_msat, 10_000); // 10-sat floor + assert_eq!(est.amount_msat, 490_000); // 490 sats + } + + #[test] + fn two_usable_lsp_channels_sum() { + // mdk does not bake in a single-channel assumption — if two + // usable LSP channels exist (rare but legal), their + // `next_outbound_htlc_limit_msat` values sum. + let lsp = lsp(); + let chans = [ + snap(lsp, true, 50_000_000), // 50k sats + snap(lsp, true, 30_000_000), // 30k sats + ]; + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.balance_at_compute_msat, 80_000_000); + assert_eq!(est.fee_budget_msat, 800_000); // 1% of 80k sats + assert_eq!(est.amount_msat, 79_200_000); + } + + #[test] + fn mixed_channels_only_usable_lsp_contributes() { + // Only the usable LSP channel counts: non-LSP and + // unusable-LSP entries are filtered out. + let lsp = lsp(); + let other = other_peer(); + let chans = [ + snap(lsp, true, 10_000_000), // counts + snap(other, true, 50_000_000), // wrong peer + snap(lsp, false, 100_000_000), // mid-open/splice + ]; + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.balance_at_compute_msat, 10_000_000); + assert_eq!(est.fee_budget_msat, 100_000); // 1% of 10k sats + assert_eq!(est.amount_msat, 9_900_000); + } + + #[test] + fn overrides_take_effect() { + // Custom bps and floor flow through end-to-end. 200 bps = 2%. + let lsp = lsp(); + let chans = [snap(lsp, true, 1_000_000_000)]; // 1M sats + let cfg = MaxSendableConfig { + fee_buffer_bps: 200, + fee_buffer_floor_sats: 50, + }; + let est = compute_estimate(&chans, &lsp, &cfg).unwrap(); + assert_eq!(est.fee_budget_msat, 20_000_000); // 2% of 1M sats = 20k sats + assert_eq!(est.amount_msat, 980_000_000); + } +} diff --git a/src/mdk/mod.rs b/src/mdk/mod.rs index 5b3adaa..5257287 100644 --- a/src/mdk/mod.rs +++ b/src/mdk/mod.rs @@ -1,6 +1,7 @@ pub mod client; pub mod config; pub mod error; +pub mod max_sendable; pub mod mdk_api; pub mod node; pub mod splice_manager; diff --git a/src/mdk/node.rs b/src/mdk/node.rs index 2744e5d..a6ddafe 100644 --- a/src/mdk/node.rs +++ b/src/mdk/node.rs @@ -19,6 +19,7 @@ use log::{info, warn}; use crate::mdk::config::{ChainSource, NetworkInfra}; use crate::mdk::error::MdkError; +use crate::mdk::max_sendable::MaxSendableConfig; pub struct NodeConfig { pub network: Network, @@ -32,6 +33,7 @@ pub struct NodeConfig { pub infra: NetworkInfra, pub scoring_overrides: ScoringOverrides, pub splice: SpliceConfig, + pub max_sendable: MaxSendableConfig, } /// Per-field overrides for the probabilistic scorer's fee parameters. diff --git a/tests/integration.rs b/tests/integration.rs index ec644dc..ee43368 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1381,6 +1381,88 @@ async fn test_auto_splice_after_channel_close_and_reopen() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn test_max_sendable_on_getbalance() { + let bitcoind = TestBitcoind::new(); + let lsp = LspNode::new(&bitcoind); + fund_lsp(&bitcoind, &lsp).await; + + let server = MdkdHandle::start(&bitcoind, None, Some(&lsp), &random_mnemonic()).await; + + // No channel yet: `maxWithdrawableSat` must be null, paired with a + // zero `balanceSat`. This is the UX invariant the field exists to + // preserve (never a positive balance alongside null max). + let balance: serde_json::Value = server.get("/getbalance").await.json().await.unwrap(); + assert_eq!(balance["balanceSat"].as_u64().unwrap(), 0); + assert!( + balance["maxWithdrawableSat"].is_null(), + "Expected null maxWithdrawableSat with no channel, got {balance}" + ); + + // Open a JIT channel by paying an invoice. + let payer = PayerNode::new(&bitcoind); + setup_payer_lsp_channel(&bitcoind, &payer, &lsp, 500_000).await; + + let invoice: serde_json::Value = server + .post_form( + "/createinvoice", + &[ + ("amountSat", "100000"), + ("description", "max withdrawable e2e"), + ("expirySeconds", "3600"), + ], + ) + .await + .json() + .await + .unwrap(); + let invoice_str = invoice["serialized"].as_str().unwrap(); + let payment_hash = invoice["paymentHash"].as_str().unwrap().to_string(); + payer.pay_invoice(invoice_str); + + let start = std::time::Instant::now(); + loop { + let resp: serde_json::Value = server + .get(&format!("/payments/incoming/{payment_hash}")) + .await + .json() + .await + .unwrap(); + if resp["isPaid"].as_bool().unwrap() { + break; + } + if start.elapsed() > Duration::from_secs(60) { + panic!("Timed out waiting for JIT payment to settle"); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // With a usable LSP channel and a positive balance, the accessor + // must return Some. Defaults are 1% bps / 10-sat floor (see + // `MaxSendableConfig::Default`), so the buffer always shaves + // at least one sat off the balance. + let balance: serde_json::Value = server.get("/getbalance").await.json().await.unwrap(); + let balance_sat = balance["balanceSat"].as_u64().unwrap(); + let max_withdrawable_sat = balance["maxWithdrawableSat"] + .as_u64() + .unwrap_or_else(|| panic!("Expected Some maxWithdrawableSat, got {balance}")); + + assert!(balance_sat > 0, "Expected positive balanceSat: {balance}"); + assert!( + max_withdrawable_sat < balance_sat, + "Buffer must shave at least one sat: balance={balance_sat}, max={max_withdrawable_sat}" + ); + // `balanceSat` derives from `outbound_capacity_msat` while + // `maxWithdrawableSat` derives from `next_outbound_htlc_limit_msat`; + // the latter can be meaningfully smaller depending on HTLC state. + // Use a loose lower bound to keep the assertion stable. + assert!( + max_withdrawable_sat * 100 >= balance_sat * 90, + "max should be within 10% of balance: balance={balance_sat}, max={max_withdrawable_sat}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn test_decodeoffer_invalid() { let bitcoind = TestBitcoind::new(); diff --git a/wallet.html b/wallet.html index 5bfe4d9..697bb5b 100644 --- a/wallet.html +++ b/wallet.html @@ -277,6 +277,7 @@

MDK Wallet

Lightning Balance

+
Max sendable: —

On-chain Balance

@@ -641,6 +642,7 @@

Confirm

$('#dash-channels').textContent = info.channels.length; $('#dash-version').textContent = info.version; $('#dash-ln-balance').innerHTML = formatSats(bal.balanceSat) + ' sats'; + $('#dash-ln-max').textContent = 'Max sendable: ' + (bal.maxWithdrawableSat == null ? '—' : formatSats(bal.maxWithdrawableSat) + ' sats'); $('#dash-onchain-balance').innerHTML = formatSats(bal.onchainBalanceSat) + ' sats'; } catch (e) { console.error('Dashboard refresh failed:', e);