From ca0a897f232fc022fb6177628230426b87f01c2d Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 13 May 2026 10:51:16 -0700 Subject: [PATCH 1/7] Add max_withdrawable pure types and compute function First slice of the v0 "max sendable" estimator for better payout UX. mdk consolidates outbound liquidity on its LSP channel, so the payer needs `balance - routing_fee(amount)`, not raw balance. A precise estimate would call `Router::find_route` and invert per-hop fees, but ldk-node keeps the router private. v0 ships a destination-agnostic best-effort number: subtract a percentage buffer (default 1%, 10-sat floor) from the sum of `next_outbound_htlc_limit_msat` across usable LSP channels. v1 swaps the body of `compute_estimate` once the ldk-node fork lands. --- src/mdk/max_withdrawable.rs | 264 ++++++++++++++++++++++++++++++++++++ src/mdk/mod.rs | 1 + 2 files changed, 265 insertions(+) create mode 100644 src/mdk/max_withdrawable.rs diff --git a/src/mdk/max_withdrawable.rs b/src/mdk/max_withdrawable.rs new file mode 100644 index 0000000..344d359 --- /dev/null +++ b/src/mdk/max_withdrawable.rs @@ -0,0 +1,264 @@ +//! 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 — everything around it +//! (cache, background poll, accessor) stays put across v0→v1. + +// The core lands ahead of its consumers (background refresher and +// MdkClient accessor). Until those land, the items below are +// unreferenced — silenced module-wide here and remove once the +// wiring exists. +#![allow(dead_code)] + +use std::time::Instant; + +use ldk_node::bitcoin::secp256k1::PublicKey; + +/// User-tunable buffer applied to the raw outbound liquidity to +/// reserve headroom for routing fees. +#[derive(Debug, Clone)] +pub struct MaxWithdrawableConfig { + /// 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 MaxWithdrawableConfig { + 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 MaxWithdrawableEstimate { + /// 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 MaxWithdrawableError { + /// 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, +} + +/// 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: &MaxWithdrawableConfig, +) -> 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(MaxWithdrawableError::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(MaxWithdrawableEstimate { + 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, &MaxWithdrawableConfig::default()); + assert!(matches!(res, Err(MaxWithdrawableError::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, &MaxWithdrawableConfig::default()); + assert!(matches!(res, Err(MaxWithdrawableError::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, &MaxWithdrawableConfig::default()); + assert!(matches!(res, Err(MaxWithdrawableError::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, &MaxWithdrawableConfig::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, &MaxWithdrawableConfig::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, &MaxWithdrawableConfig::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, &MaxWithdrawableConfig::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, &MaxWithdrawableConfig::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, &MaxWithdrawableConfig::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 = MaxWithdrawableConfig { + 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..3a1b23c 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_withdrawable; pub mod mdk_api; pub mod node; pub mod splice_manager; From 37daf8fd2261f46e22c1888212061e45f1d4bb83 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 13 May 2026 11:04:35 -0700 Subject: [PATCH 2/7] Add MaxWithdrawableConfig to NodeConfig Plumb the buffer config through both the library-side `NodeConfig` and the daemon-side `MdkConfig`, so the upcoming background refresher and client accessor can reach it. TOML section is `[max_withdrawable]` with snake_case field names mirroring the Rust struct. The whole section is optional; an absent section yields `MaxWithdrawableConfig::default()` (100 bps, 10-sat floor). This matches the splice section's pattern and means existing deployments pick up sensible defaults without touching config.toml. --- src/daemon/config.rs | 23 +++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 1 + src/mdk/node.rs | 2 ++ 4 files changed, 27 insertions(+) diff --git a/src/daemon/config.rs b/src/daemon/config.rs index f4b06e6..380b16c 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_withdrawable::MaxWithdrawableConfig; use mdk::node::ScoringOverrides; use serde::Deserialize; @@ -19,6 +20,7 @@ struct TomlConfig { storage: Option, log: Option, splice: Option, + max_withdrawable: Option, } #[derive(Deserialize)] @@ -90,6 +92,12 @@ struct SpliceSection { poll_interval_secs: Option, } +#[derive(Deserialize)] +struct MaxWithdrawableSection { + 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_withdrawable: MaxWithdrawableConfig, } 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_withdrawable = match toml.max_withdrawable { + Some(s) => { + let defaults = MaxWithdrawableConfig::default(); + MaxWithdrawableConfig { + 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 => MaxWithdrawableConfig::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_withdrawable, }) } diff --git a/src/lib.rs b/src/lib.rs index d9ae4aa..a6ab546 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_withdrawable; 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..7207bc2 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_withdrawable: config_file.max_withdrawable, }; // Separate HTTP client for daemon concerns (webhooks, expiry monitor). diff --git a/src/mdk/node.rs b/src/mdk/node.rs index 2744e5d..3b74770 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_withdrawable::MaxWithdrawableConfig; 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_withdrawable: MaxWithdrawableConfig, } /// Per-field overrides for the probabilistic scorer's fee parameters. From 3cefd553468293005f9bd2b474adfc589a3f3213 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 13 May 2026 11:23:26 -0700 Subject: [PATCH 3/7] Add MdkClient::max_withdrawable accessor Inline-compute accessor over `node.list_channels()`: projects each `ChannelDetails` into `ChannelSnapshot`, calls the pure `compute_estimate`, returns. No cache, no background task, no lifecycle wiring. --- src/mdk/client.rs | 21 +++++++++++++++++++++ src/mdk/max_withdrawable.rs | 21 +++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/mdk/client.rs b/src/mdk/client.rs index a74b816..15293fd 100644 --- a/src/mdk/client.rs +++ b/src/mdk/client.rs @@ -15,6 +15,10 @@ use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use crate::mdk::error::{MdkError, SpliceError}; +use crate::mdk::max_withdrawable::{ + compute_estimate, ChannelSnapshot, MaxWithdrawableConfig, MaxWithdrawableError, + MaxWithdrawableEstimate, +}; use crate::mdk::mdk_api::client::MdkApiClient; use crate::mdk::mdk_api::types::{ CheckoutCustomer, CreateCheckoutRequest, PaymentEntry, PaymentReceivedRequest, @@ -37,6 +41,7 @@ pub struct MdkClient { api: Arc, lsp_pubkey: PublicKey, splice_cfg: SpliceConfig, + max_withdrawable_cfg: MaxWithdrawableConfig, event_tx: broadcast::Sender, event_handler: Option, shutdown: CancellationToken, @@ -77,6 +82,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_withdrawable_cfg = config.max_withdrawable.clone(); let node = build_node(config, handle.clone())?; let http_client = build_http_client(socks_proxy.as_deref())?; @@ -92,6 +98,7 @@ impl MdkClient { api, lsp_pubkey, splice_cfg, + max_withdrawable_cfg, event_tx, event_handler, shutdown: CancellationToken::new(), @@ -137,6 +144,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_withdrawable(&self) -> Result { + let snaps: Vec = self + .node + .list_channels() + .iter() + .map(ChannelSnapshot::from) + .collect(); + compute_estimate(&snaps, &self.lsp_pubkey, &self.max_withdrawable_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_withdrawable.rs b/src/mdk/max_withdrawable.rs index 344d359..bd1696b 100644 --- a/src/mdk/max_withdrawable.rs +++ b/src/mdk/max_withdrawable.rs @@ -5,18 +5,13 @@ //! 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 — everything around it -//! (cache, background poll, accessor) stays put across v0→v1. - -// The core lands ahead of its consumers (background refresher and -// MdkClient accessor). Until those land, the items below are -// unreferenced — silenced module-wide here and remove once the -// wiring exists. -#![allow(dead_code)] +//! 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. @@ -73,6 +68,16 @@ pub(crate) struct ChannelSnapshot { 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. /// From f878c6d9a653e8c7ed47517b32a84666fb72a50b Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 13 May 2026 11:31:53 -0700 Subject: [PATCH 4/7] Add max_withdrawable_sat to /getbalance response Surfaces the new accessor over HTTP. The payouts UX needs a "max sendable" number to drive a one-click drain a destination, and the raw `balance_sat` overstates that by the routing fee. The handler now takes `Arc` instead of `Arc` so it can reach both the node (for the existing balance math) and the new accessor. `AppState.mdk_client` was already in scope; the route wiring change is a one-liner. `Option` semantics preserved end-to-end: `null` means no usable LSP channel, `Some(0)` means the channel exists but liquidity is fully consumed by the fee buffer. The UX invariant ("never positive `balanceSat` alongside `null` `maxWithdrawableSat`") holds because both fields project from the same `list_channels()` snapshot inside a single request. JSON wire name is `maxWithdrawableSat` via the existing camelCase serde rename on the struct. --- src/daemon/api/balance.rs | 14 ++++++++++++-- src/daemon/api/mod.rs | 2 +- src/daemon/types.rs | 6 ++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/daemon/api/balance.rs b/src/daemon/api/balance.rs index 3349460..481559d 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_withdrawable`]). `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/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)] From ddca68d077dfc48370b4d926e1c9ba609954921d Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 13 May 2026 11:39:54 -0700 Subject: [PATCH 5/7] Add end-to-end test for max_withdrawable on /getbalance Locks in the wire contract that consumers read: `maxWithdrawableSat` is null with no channel, turns into a positive number once a usable LSP channel exists, and sits below `balanceSat` once the fee buffer kicks in. The value cross-check uses a 10% tolerance rather than a tight bound against the configured 1% buffer. `balanceSat` projects from `outbound_capacity_msat` while `maxWithdrawableSat` projects from `next_outbound_htlc_limit_msat`, which is further reduced by per-HTLC limits, dust exposure, and in-flight HTLCs. A tight bound would go flaky for reasons unrelated to the buffer math. The pre-channel case is the one that really matters for the UX invariant: never positive `balanceSat` with null `maxWithdrawableSat`. Both fields come from the same `list_channels()` snapshot inside a single request, so they can't disagree. --- tests/integration.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/integration.rs b/tests/integration.rs index ec644dc..8716a62 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_withdrawable_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 + // `MaxWithdrawableConfig::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(); From 794c8772526ce7d4710c9ac2b46844faa7f04250 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 13 May 2026 12:08:19 -0700 Subject: [PATCH 6/7] Show max sendable on the demo wallet dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the new `maxWithdrawableSat` field from `/getbalance` as a sub-line under the Lightning Balance card so the demo wallet matches what lightning-js on moneydevkit.com will render. Null renders as an em dash rather than being hidden. The Lightning Balance card itself only ever shows zero (not null), so a visible "Max sendable: —" alongside a zero balance reinforces the UX invariant: there is no usable LSP channel right now. --- wallet.html | 2 ++ 1 file changed, 2 insertions(+) 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); From e5c3d66501f5d189d0bde7a462f99173374a1907 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 13 May 2026 14:26:53 -0700 Subject: [PATCH 7/7] Rename max_withdrawable to max_sendable The accessor is destination-agnostic; it returns how much can be sent over Lightning right now. Withdrawal-to-configured-address is a higher-level concern. v1 will extend this with a destination-aware variant, where the BOLT11 case in particular makes no sense to call a "withdrawal". The JSON field `max_withdrawable_sat` on `/getbalance` is preserved since that specific endpoint is the withdrawal-display contract. That endpoint is in the daemon which is the higher level consumer. This will be similar to how mdk-checkout uses this API but instead using the configured WITHDRAWL_DESTINATION. --- src/daemon/api/balance.rs | 4 +- src/daemon/config.rs | 18 ++++---- src/lib.rs | 2 +- src/main.rs | 2 +- src/mdk/client.rs | 15 ++++--- .../{max_withdrawable.rs => max_sendable.rs} | 42 +++++++++---------- src/mdk/mod.rs | 2 +- src/mdk/node.rs | 4 +- tests/integration.rs | 4 +- 9 files changed, 46 insertions(+), 47 deletions(-) rename src/mdk/{max_withdrawable.rs => max_sendable.rs} (86%) diff --git a/src/daemon/api/balance.rs b/src/daemon/api/balance.rs index 481559d..8ded8e8 100644 --- a/src/daemon/api/balance.rs +++ b/src/daemon/api/balance.rs @@ -22,7 +22,7 @@ use crate::daemon::types::GetBalanceResponse; /// `onchain_balance_sat` is what the user can actually sweep/send on-chain right now. /// /// `max_withdrawable_sat` is what `balance_sat` can pay out after subtracting -/// a routing-fee buffer (see [`mdk::max_withdrawable`]). `None` when no usable +/// a routing-fee buffer (see [`mdk::max_sendable`]). `None` when no usable /// LSP channel exists. pub async fn handle_get_balance( client: Arc, @@ -35,7 +35,7 @@ pub async fn handle_get_balance( .map(|ch| ch.outbound_capacity_msat / 1000) .sum(); - let max_withdrawable_sat = client.max_withdrawable().ok().map(|e| e.amount_msat / 1000); + let max_withdrawable_sat = client.max_sendable().ok().map(|e| e.amount_msat / 1000); Ok(Json(GetBalanceResponse { balance_sat: lightning_sat, diff --git a/src/daemon/config.rs b/src/daemon/config.rs index 380b16c..0b5da5d 100644 --- a/src/daemon/config.rs +++ b/src/daemon/config.rs @@ -8,7 +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_withdrawable::MaxWithdrawableConfig; +use mdk::max_sendable::MaxSendableConfig; use mdk::node::ScoringOverrides; use serde::Deserialize; @@ -20,7 +20,7 @@ struct TomlConfig { storage: Option, log: Option, splice: Option, - max_withdrawable: Option, + max_sendable: Option, } #[derive(Deserialize)] @@ -93,7 +93,7 @@ struct SpliceSection { } #[derive(Deserialize)] -struct MaxWithdrawableSection { +struct MaxSendableSection { fee_buffer_bps: Option, fee_buffer_floor_sats: Option, } @@ -109,7 +109,7 @@ pub struct MdkConfig { pub pathfinding_scores_source_url: Option, pub scoring_overrides: ScoringOverrides, pub splice: SpliceConfig, - pub max_withdrawable: MaxWithdrawableConfig, + pub max_sendable: MaxSendableConfig, } pub fn load_config(path: &str) -> io::Result { @@ -182,17 +182,17 @@ pub fn load_config(path: &str) -> io::Result { None => SpliceConfig::default(), }; - let max_withdrawable = match toml.max_withdrawable { + let max_sendable = match toml.max_sendable { Some(s) => { - let defaults = MaxWithdrawableConfig::default(); - MaxWithdrawableConfig { + 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 => MaxWithdrawableConfig::default(), + None => MaxSendableConfig::default(), }; Ok(MdkConfig { @@ -206,7 +206,7 @@ pub fn load_config(path: &str) -> io::Result { pathfinding_scores_source_url: node.pathfinding_scores_source_url, scoring_overrides, splice, - max_withdrawable, + max_sendable, }) } diff --git a/src/lib.rs b/src/lib.rs index a6ab546..db99f3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,7 @@ mod mdk; pub use mdk::client; pub use mdk::config; pub use mdk::error; -pub use mdk::max_withdrawable; +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 7207bc2..1673453 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,7 +133,7 @@ fn main() { infra, scoring_overrides: config_file.scoring_overrides, splice: config_file.splice, - max_withdrawable: config_file.max_withdrawable, + 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 15293fd..23d95f0 100644 --- a/src/mdk/client.rs +++ b/src/mdk/client.rs @@ -15,9 +15,8 @@ use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use crate::mdk::error::{MdkError, SpliceError}; -use crate::mdk::max_withdrawable::{ - compute_estimate, ChannelSnapshot, MaxWithdrawableConfig, MaxWithdrawableError, - MaxWithdrawableEstimate, +use crate::mdk::max_sendable::{ + compute_estimate, ChannelSnapshot, MaxSendableConfig, MaxSendableError, MaxSendableEstimate, }; use crate::mdk::mdk_api::client::MdkApiClient; use crate::mdk::mdk_api::types::{ @@ -41,7 +40,7 @@ pub struct MdkClient { api: Arc, lsp_pubkey: PublicKey, splice_cfg: SpliceConfig, - max_withdrawable_cfg: MaxWithdrawableConfig, + max_sendable_cfg: MaxSendableConfig, event_tx: broadcast::Sender, event_handler: Option, shutdown: CancellationToken, @@ -82,7 +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_withdrawable_cfg = config.max_withdrawable.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())?; @@ -98,7 +97,7 @@ impl MdkClient { api, lsp_pubkey, splice_cfg, - max_withdrawable_cfg, + max_sendable_cfg, event_tx, event_handler, shutdown: CancellationToken::new(), @@ -148,14 +147,14 @@ impl MdkClient { /// 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_withdrawable(&self) -> Result { + 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_withdrawable_cfg) + compute_estimate(&snaps, &self.lsp_pubkey, &self.max_sendable_cfg) } /// Splice `amount_sats` of confirmed on-chain funds into the diff --git a/src/mdk/max_withdrawable.rs b/src/mdk/max_sendable.rs similarity index 86% rename from src/mdk/max_withdrawable.rs rename to src/mdk/max_sendable.rs index bd1696b..0d2ef87 100644 --- a/src/mdk/max_withdrawable.rs +++ b/src/mdk/max_sendable.rs @@ -16,7 +16,7 @@ use ldk_node::ChannelDetails; /// User-tunable buffer applied to the raw outbound liquidity to /// reserve headroom for routing fees. #[derive(Debug, Clone)] -pub struct MaxWithdrawableConfig { +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. @@ -25,7 +25,7 @@ pub struct MaxWithdrawableConfig { pub fee_buffer_floor_sats: u64, } -impl Default for MaxWithdrawableConfig { +impl Default for MaxSendableConfig { fn default() -> Self { Self { fee_buffer_bps: 100, @@ -37,7 +37,7 @@ impl Default for MaxWithdrawableConfig { /// 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 MaxWithdrawableEstimate { +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, @@ -52,7 +52,7 @@ pub struct MaxWithdrawableEstimate { } #[derive(Debug, PartialEq, Eq)] -pub enum MaxWithdrawableError { +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)`). @@ -95,8 +95,8 @@ impl From<&ChannelDetails> for ChannelSnapshot { pub(crate) fn compute_estimate( channels: &[ChannelSnapshot], lsp_pubkey: &PublicKey, - cfg: &MaxWithdrawableConfig, -) -> Result { + cfg: &MaxSendableConfig, +) -> Result { // `Option` accumulator distinguishes "no channel matched" // (None → NoUsableChannel) from "channel(s) matched, sum is 0" // (Some(0) → Ok with dust semantics). @@ -109,7 +109,7 @@ pub(crate) fn compute_estimate( .saturating_add(c.next_outbound_htlc_limit_msat), ) }) - .ok_or(MaxWithdrawableError::NoUsableChannel)?; + .ok_or(MaxSendableError::NoUsableChannel)?; // u128 intermediate dodges overflow at the percentage step. ppm // basis-points × u64 msat fits in u128 trivially, and the divide @@ -118,7 +118,7 @@ pub(crate) fn compute_estimate( let floor_buffer = cfg.fee_buffer_floor_sats.saturating_mul(1_000); let buffer_msat = pct_buffer.max(floor_buffer); - Ok(MaxWithdrawableEstimate { + Ok(MaxSendableEstimate { amount_msat: balance_msat.saturating_sub(buffer_msat), fee_budget_msat: buffer_msat, balance_at_compute_msat: balance_msat, @@ -152,16 +152,16 @@ mod tests { #[test] fn no_usable_channel_when_empty() { let lsp = lsp(); - let res = compute_estimate(&[], &lsp, &MaxWithdrawableConfig::default()); - assert!(matches!(res, Err(MaxWithdrawableError::NoUsableChannel))); + 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, &MaxWithdrawableConfig::default()); - assert!(matches!(res, Err(MaxWithdrawableError::NoUsableChannel))); + let res = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()); + assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } #[test] @@ -170,8 +170,8 @@ mod tests { // — explicitly distinct from "balance is zero". let lsp = lsp(); let chans = [snap(lsp, false, 100_000_000)]; - let res = compute_estimate(&chans, &lsp, &MaxWithdrawableConfig::default()); - assert!(matches!(res, Err(MaxWithdrawableError::NoUsableChannel))); + let res = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()); + assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } #[test] @@ -182,7 +182,7 @@ mod tests { // error. let lsp = lsp(); let chans = [snap(lsp, true, 5_000)]; // 5 sats - let est = compute_estimate(&chans, &lsp, &MaxWithdrawableConfig::default()).unwrap(); + 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); @@ -194,7 +194,7 @@ mod tests { // fee_budget = 10_000 msat. let lsp = lsp(); let chans = [snap(lsp, true, 10_000)]; - let est = compute_estimate(&chans, &lsp, &MaxWithdrawableConfig::default()).unwrap(); + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.amount_msat, 0); assert_eq!(est.fee_budget_msat, 10_000); } @@ -204,7 +204,7 @@ mod tests { // 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, &MaxWithdrawableConfig::default()).unwrap(); + 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); @@ -215,7 +215,7 @@ mod tests { // 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, &MaxWithdrawableConfig::default()).unwrap(); + 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 } @@ -230,7 +230,7 @@ mod tests { snap(lsp, true, 50_000_000), // 50k sats snap(lsp, true, 30_000_000), // 30k sats ]; - let est = compute_estimate(&chans, &lsp, &MaxWithdrawableConfig::default()).unwrap(); + 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); @@ -247,7 +247,7 @@ mod tests { snap(other, true, 50_000_000), // wrong peer snap(lsp, false, 100_000_000), // mid-open/splice ]; - let est = compute_estimate(&chans, &lsp, &MaxWithdrawableConfig::default()).unwrap(); + 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); @@ -258,7 +258,7 @@ mod tests { // 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 = MaxWithdrawableConfig { + let cfg = MaxSendableConfig { fee_buffer_bps: 200, fee_buffer_floor_sats: 50, }; diff --git a/src/mdk/mod.rs b/src/mdk/mod.rs index 3a1b23c..5257287 100644 --- a/src/mdk/mod.rs +++ b/src/mdk/mod.rs @@ -1,7 +1,7 @@ pub mod client; pub mod config; pub mod error; -pub mod max_withdrawable; +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 3b74770..a6ddafe 100644 --- a/src/mdk/node.rs +++ b/src/mdk/node.rs @@ -19,7 +19,7 @@ use log::{info, warn}; use crate::mdk::config::{ChainSource, NetworkInfra}; use crate::mdk::error::MdkError; -use crate::mdk::max_withdrawable::MaxWithdrawableConfig; +use crate::mdk::max_sendable::MaxSendableConfig; pub struct NodeConfig { pub network: Network, @@ -33,7 +33,7 @@ pub struct NodeConfig { pub infra: NetworkInfra, pub scoring_overrides: ScoringOverrides, pub splice: SpliceConfig, - pub max_withdrawable: MaxWithdrawableConfig, + 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 8716a62..ee43368 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1382,7 +1382,7 @@ async fn test_auto_splice_after_channel_close_and_reopen() { } #[tokio::test(flavor = "multi_thread")] -async fn test_max_withdrawable_on_getbalance() { +async fn test_max_sendable_on_getbalance() { let bitcoind = TestBitcoind::new(); let lsp = LspNode::new(&bitcoind); fund_lsp(&bitcoind, &lsp).await; @@ -1440,7 +1440,7 @@ async fn test_max_withdrawable_on_getbalance() { // With a usable LSP channel and a positive balance, the accessor // must return Some. Defaults are 1% bps / 10-sat floor (see - // `MaxWithdrawableConfig::Default`), so the buffer always shaves + // `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();