From 495261a45fb046158e08f779bde7e5dc949b5641 Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 19 May 2026 08:09:08 -0700 Subject: [PATCH 1/5] Bump ldk-node rev Sets up the destination-aware max-sendable work from mdkd PR #22. The new ldk-node rev exposes Node::find_route so the estimator can walk a real route and subtract its fees instead of falling back to a static buffer. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f5eb862..0f15387 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ bitcoin-payment-instructions = { git = "https://github.com/moneydevkit/bitcoin-p "http", ] } # Branch: https://github.com/moneydevkit/ldk-node/commits/lsp-0.7.0_accept-underpaying-htlcs_with_timing_logs -ldk-node = { default-features = false, git = "https://github.com/moneydevkit/ldk-node.git", rev = "5dce44b6e795560bbf62f49d3648308ce88a0586" } +ldk-node = { default-features = false, git = "https://github.com/moneydevkit/ldk-node.git", rev = "5616db4ed0db15534d6d92356fef8c5bad79acb1" } #ldk-node = { path = "../ldk-node" } napi = { version = "2", features = ["napi4"] } From f775974c7439f31ed6d80f2103cf939e0babb0bc Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 19 May 2026 12:14:12 -0700 Subject: [PATCH 2/5] Add route retry multiplier to max-sendable config The destination-aware path coming next needs a knob for "how much above the cheapest route's fees should we reserve so retries can take a costlier path". Park it on an internal config struct now so the napi layer and the estimator share defaults. sum_outbound_balance and subtract_fee_buffer are split out of compute_estimate ahead of their second caller. The route-based strategy in the next commit reuses sum_outbound_balance to size the find_route amount and keeps subtract_fee_buffer as the no-destination arm of the strategy match. Splitting now keeps that diff to pure additions. --- src/lib.rs | 51 ++++++++++--------- src/max_sendable.rs | 118 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 121 insertions(+), 48 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6e1d90c..b9f62df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -342,13 +342,6 @@ impl ResolvedSpliceConfig { } } -/// Default percentage buffer (in basis points) applied when the caller -/// leaves `fee_buffer_bps` unset on [`MaxSendableConfig`]. 100 bps = 1 %. -const DEFAULT_FEE_BUFFER_BPS: u16 = 100; -/// Default floor (in sats) on the buffer when the caller leaves -/// `fee_buffer_floor_sats` unset on [`MaxSendableConfig`]. -const DEFAULT_FEE_BUFFER_FLOOR_SATS: u64 = 10; - /// Configuration for the max-sendable estimator. Subtracts a routing-fee /// buffer from the raw outbound liquidity so consumers don't try to spend /// `getBalance()` worth and watch it fail to route. @@ -359,23 +352,34 @@ pub struct MaxSendableConfig { pub fee_buffer_bps: Option, /// Absolute lower bound on the buffer, in sats. Default: 10. pub fee_buffer_floor_sats: Option, + /// Fee budget multiplier in basis points of the cheapest route's + /// total fees (10_000 = 1.0x, 20_000 = 2.0x). Reserved so a send has + /// headroom to retry along a costlier path. Default: 11_000 (1.1x). + pub route_retry_fee_multiplier_bps: Option, } impl MaxSendableConfig { /// Resolve the napi-shaped optional/wider-typed config into the - /// `(bps, floor_sats)` pair that `max_sendable::compute_estimate` - /// consumes. Bad input from JS (negative floor, bps > `u16::MAX`) - /// gets clamped silently - fn resolve(&self) -> (u16, u64) { - let bps = self - .fee_buffer_bps - .map(|v| v.min(u16::MAX as u32) as u16) - .unwrap_or(DEFAULT_FEE_BUFFER_BPS); - let floor_sats = self - .fee_buffer_floor_sats - .map(|v| v.max(0) as u64) - .unwrap_or(DEFAULT_FEE_BUFFER_FLOOR_SATS); - (bps, floor_sats) + /// internal [`max_sendable::MaxSendableConfig`] consumed by + /// `compute_estimate`. Bad input from JS (negative floor, bps > + /// `u16::MAX`) gets clamped silently. Unset fields fall back to the + /// internal struct's `Default`. + fn resolve(&self) -> max_sendable::MaxSendableConfig { + let defaults = max_sendable::MaxSendableConfig::default(); + max_sendable::MaxSendableConfig { + fee_buffer_bps: self + .fee_buffer_bps + .map(|v| v.min(u16::MAX as u32) as u16) + .unwrap_or(defaults.fee_buffer_bps), + fee_buffer_floor_sats: self + .fee_buffer_floor_sats + .map(|v| v.max(0) as u64) + .unwrap_or(defaults.fee_buffer_floor_sats), + route_retry_fee_multiplier_bps: self + .route_retry_fee_multiplier_bps + .map(|v| v.min(u16::MAX as u32) as u16) + .unwrap_or(defaults.route_retry_fee_multiplier_bps), + } } } @@ -464,7 +468,7 @@ pub struct MdkNode { /// channels by counterparty. lsp_pubkey: PublicKey, splice_cfg: ResolvedSpliceConfig, - max_sendable_cfg: MaxSendableConfig, + max_sendable_cfg: max_sendable::MaxSendableConfig, /// One-worker tokio runtime dedicated to the splice manager. splice_runtime: Runtime, /// `Some` while a splice manager is running, `None` otherwise. @@ -578,7 +582,7 @@ impl MdkNode { .map_err(|err| napi::Error::from_reason(err.to_string()))?; let splice_cfg = ResolvedSpliceConfig::from_options(options.splice); - let max_sendable_cfg = options.max_sendable.unwrap_or_default(); + let max_sendable_cfg = options.max_sendable.unwrap_or_default().resolve(); // One self-driving worker is enough; the manager sleeps between ticks. let splice_runtime = tokio::runtime::Builder::new_multi_thread() @@ -956,8 +960,7 @@ impl MdkNode { .iter() .map(max_sendable::ChannelSnapshot::from) .collect(); - let (bps, floor_sats) = self.max_sendable_cfg.resolve(); - max_sendable::compute_estimate(&snaps, &self.lsp_pubkey, bps, floor_sats) + max_sendable::compute_estimate(&snaps, &self.lsp_pubkey, &self.max_sendable_cfg) .ok() .map(|e| MaxSendableEstimate { amount_msat: u64_to_i64(e.amount_msat), diff --git a/src/max_sendable.rs b/src/max_sendable.rs index 420ab82..1f04e52 100644 --- a/src/max_sendable.rs +++ b/src/max_sendable.rs @@ -11,6 +11,39 @@ use ldk_node::ChannelDetails; use ldk_node::bitcoin::secp256k1::PublicKey; +/// User-tunable buffers applied to the raw outbound liquidity (when +/// no destination is supplied) or to a destination-aware route's +/// computed fees, to reserve headroom for routing fees. +#[derive(Debug, Clone)] +pub(crate) struct MaxSendableConfig { + /// Percentage buffer in basis points (1 bps = 0.01 %), applied + /// to the outbound balance when no destination is supplied. + /// Default: 100 (1 %). + pub fee_buffer_bps: u16, + /// Absolute lower bound on the no-destination buffer, in sats. + /// Whichever of the percentage and the floor is larger wins. + /// Default: 10. + pub fee_buffer_floor_sats: u64, + /// Fee budget multiplier in basis points of the cheapest route's + /// computed `total_fees`, where 10_000 = 1.0x (no buffer) and + /// 20_000 = 2.0x. Reserved so a send has headroom to retry along + /// a more expensive path if the chosen route fails at payment + /// time. Larger values raise payment success rate at the cost of + /// a lower reported max sendable. Default: 11_000 (1.1x); bump + /// up if production retries surface "insufficient fee budget". + pub route_retry_fee_multiplier_bps: u16, +} + +impl Default for MaxSendableConfig { + fn default() -> Self { + Self { + fee_buffer_bps: 100, + fee_buffer_floor_sats: 10, + route_retry_fee_multiplier_bps: 11_000, + } + } +} + /// 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)] @@ -63,10 +96,34 @@ pub(crate) fn compute_estimate( fee_buffer_bps: u16, fee_buffer_floor_sats: u64, ) -> 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 + let balance_msat = sum_outbound_balance(channels, lsp_pubkey)?; + Ok(subtract_fee_buffer(balance_msat, cfg)) +} + +/// Subtract the configured fee buffer from a known outbound balance. +fn subtract_fee_buffer(balance_msat: u64, cfg: &MaxSendableConfig) -> MaxSendableEstimate { + // 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); + + MaxSendableEstimate { + amount_msat: balance_msat.saturating_sub(buffer_msat), + fee_budget_msat: buffer_msat, + } +} + +/// Sum `next_outbound_htlc_limit_msat` over usable channels with +/// the LSP. The `Option` accumulator distinguishes "no channel +/// matched" (None → `NoUsableChannel`) from "channel(s) matched, +/// sum is 0" (Some(0) → dust). +fn sum_outbound_balance( + channels: &[ChannelSnapshot], + lsp_pubkey: &PublicKey, +) -> Result { + channels .iter() .filter(|c| c.counterparty == *lsp_pubkey && c.is_usable) .fold(None::, |acc, c| { @@ -89,6 +146,7 @@ pub(crate) fn compute_estimate( amount_msat: balance_msat.saturating_sub(buffer_msat), fee_budget_msat: buffer_msat, }) + .ok_or(MaxSendableError::NoUsableChannel) } #[cfg(test)] @@ -96,9 +154,6 @@ mod tests { use super::*; use std::str::FromStr; - const BPS: u16 = 100; - const FLOOR: u64 = 10; - fn lsp() -> PublicKey { PublicKey::from_str("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") .unwrap() @@ -121,6 +176,8 @@ mod tests { fn no_usable_channel_when_empty() { let lsp = lsp(); let res = compute_estimate(&[], &lsp, BPS, FLOOR); + let res = compute_estimate(&[], &lsp, &MaxSendableConfig::default()); + let res = compute_estimate(&[], &lsp(), &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } @@ -134,8 +191,7 @@ mod tests { #[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". + // Mid-open/splice — distinct from "balance is zero". let lsp = lsp(); let chans = [snap(lsp, false, 100_000_000)]; let res = compute_estimate(&chans, &lsp, BPS, FLOOR); @@ -144,21 +200,18 @@ mod tests { #[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. + // 5 sats < 10-sat floor → buffer wins, amount saturates to 0. let lsp = lsp(); let chans = [snap(lsp, true, 5_000)]; // 5 sats let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); + let chans = [snap(lsp, true, 5_000)]; + 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 } #[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, BPS, FLOOR).unwrap(); @@ -174,6 +227,10 @@ mod tests { let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); assert_eq!(est.fee_budget_msat, 1_000_000); // 1000 sats assert_eq!(est.amount_msat, 99_000_000); // 99k sats + let chans = [snap(lsp, true, 100_000_000)]; + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.fee_budget_msat, 1_000_000); + assert_eq!(est.amount_msat, 99_000_000); } #[test] @@ -184,13 +241,15 @@ mod tests { let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); assert_eq!(est.fee_budget_msat, 10_000); // 10-sat floor assert_eq!(est.amount_msat, 490_000); // 490 sats + let chans = [snap(lsp, true, 500_000)]; + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.fee_budget_msat, 10_000); + assert_eq!(est.amount_msat, 490_000); } #[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. + // No single-channel assumption: two usable LSP channels sum. let lsp = lsp(); let chans = [ snap(lsp, true, 50_000_000), // 50k sats @@ -198,32 +257,43 @@ mod tests { ]; let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); assert_eq!(est.fee_budget_msat, 800_000); // 1% of 80k sats + let chans = [snap(lsp, true, 50_000_000), snap(lsp, true, 30_000_000)]; + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.fee_budget_msat, 800_000); 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. + // 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 + snap(lsp, true, 10_000_000), + snap(other, true, 50_000_000), + snap(lsp, false, 100_000_000), ]; let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); assert_eq!(est.fee_budget_msat, 100_000); // 1% of 10k sats + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + assert_eq!(est.fee_budget_msat, 100_000); 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 est = compute_estimate(&chans, &lsp, 200, 50).unwrap(); assert_eq!(est.fee_budget_msat, 20_000_000); // 2% of 1M sats = 20k sats + let chans = [snap(lsp, true, 1_000_000_000)]; + let cfg = MaxSendableConfig { + fee_buffer_bps: 200, + fee_buffer_floor_sats: 50, + ..MaxSendableConfig::default() + }; + let est = compute_estimate(&chans, &lsp, &cfg).unwrap(); + assert_eq!(est.fee_budget_msat, 20_000_000); // 2% of 1M sats assert_eq!(est.amount_msat, 980_000_000); } } From 6a3483859e735cd70da3a896f4a5d0d1d2187927 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 20 May 2026 07:25:56 -0700 Subject: [PATCH 3/5] Thread destination through max_sendable via find_route Reshape compute_estimate to accept Option<&PaymentInstructions> and a find_route closure so the next commit can expose a destination param on getMaxSendable. JS behaviour is unchanged: the napi accessor still passes None, hitting the same buffer path as before. The destination dispatch lives in compute_estimate via a small EstimationStrategy enum, with pick_strategy peeling apart the ConfigurableAmount methods iterator (the 0.6 fork's PossiblyResolvedPaymentMethod). FixedAmount destinations return Err(FixedAmount { amount_msat }) so the caller does not have to re-extract the amount; on-chain-only returns NoLightningMethod; find_route bubbles up as RoutingFailure(String). estimate_from_route prices a route at route.total_fees * route_retry_fee_multiplier_bps / 10_000. total_fees is computed from a find_route call sized to the full balance, so the reported budget is slightly conservative (actual fees on the smaller sent amount will be lower). The multiplier reserves headroom for LDK to retry along a costlier path if the chosen route fails at send time. BOLT12 offers, LNURL-pay, and HRN destinations fall back to the buffer until the upstream invoice/HRN resolution work lands. BOLT11 round-trips through bech32 because bitcoin-payment- instructions pulls upstream rust-lightning while ldk-node pulls the moneydevkit fork: wire-compatible, distinct Rust types. A re-parse failure falls back to Buffer rather than panicking. --- src/lib.rs | 18 ++-- src/max_sendable.rs | 251 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 246 insertions(+), 23 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b9f62df..5a1f54a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -960,12 +960,18 @@ impl MdkNode { .iter() .map(max_sendable::ChannelSnapshot::from) .collect(); - max_sendable::compute_estimate(&snaps, &self.lsp_pubkey, &self.max_sendable_cfg) - .ok() - .map(|e| MaxSendableEstimate { - amount_msat: u64_to_i64(e.amount_msat), - fee_budget_msat: u64_to_i64(e.fee_budget_msat), - }) + max_sendable::compute_estimate( + None, + &snaps, + &self.lsp_pubkey, + &self.max_sendable_cfg, + |rp| self.node().find_route(rp).map_err(|e| format!("{e:?}")), + ) + .ok() + .map(|e| MaxSendableEstimate { + amount_msat: u64_to_i64(e.amount_msat), + fee_budget_msat: u64_to_i64(e.fee_budget_msat), + }) } /// Manually sync the RGS snapshot. diff --git a/src/max_sendable.rs b/src/max_sendable.rs index 1f04e52..284de49 100644 --- a/src/max_sendable.rs +++ b/src/max_sendable.rs @@ -1,15 +1,37 @@ //! 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. +//! Entry point: [`compute_estimate`]. The caller collects channel +//! state and provides a `find_route` closure; this module owns the +//! `dest` dispatch and the choice between buffer and route-based +//! estimators. +//! +//! # Coverage +//! +//! | Destination | Behaviour | +//! |------------------------------|----------------------------| +//! | None | buffer | +//! | BOLT11, amount set by payee | `Err(FixedAmount)` | +//! | BOLT11, zero-amount | route-fee estimate | +//! | BOLT12 offer | buffer (TODO) | +//! | LNURL-pay | buffer (TODO) | +//! | HRN (BIP 353 / LN address) | buffer (TODO) | +//! | Onchain only | `Err(NoLightningMethod)` | +//! | `find_route` fails | `Err(RoutingFailure(msg))` | +//! +//! TODO rows fall back to the buffer rather than overstate. BOLT12 +//! needs a `from_bolt12_invoice` route once the invoice fetch lands +//! upstream. LNURL-pay and HRN destinations need to be resolved into +//! a concrete BOLT11/BOLT12 invoice before this module can route +//! against them; resolution will move into this module shortly. +use bitcoin_payment_instructions::{ + PaymentInstructions, PaymentMethod, PossiblyResolvedPaymentMethod, +}; use ldk_node::ChannelDetails; use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning_invoice::Bolt11Invoice as LdkBolt11Invoice; +use ldk_node::{PaymentParameters, Route, RouteParameters}; /// User-tunable buffers applied to the raw outbound liquidity (when /// no destination is supplied) or to a destination-aware route's @@ -58,14 +80,22 @@ pub(crate) struct MaxSendableEstimate { #[derive(Debug, PartialEq, Eq)] pub(crate) 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)`). + /// No usable LSP channel exists. Distinct from "balance is dust" + /// (which returns `Ok(amount_msat: 0)`). NoUsableChannel, + /// Payee dictated the amount; nothing to estimate. Carries the + /// amount so the caller doesn't re-extract it. + FixedAmount { amount_msat: u64 }, + /// `PaymentInstructions` carried no Lightning method (e.g. an + /// on-chain-only `bitcoin:` URI). + NoLightningMethod, + /// `Node::find_route` failed. Lossy on purpose: the caller's + /// only useful action is to retry or surface "no route". + RoutingFailure(String), } -/// Minimal projection of `ldk_node::ChannelDetails` carrying only the -/// fields [`compute_estimate`] looks at. +/// Minimal projection of `ldk_node::ChannelDetails` carrying only +/// the fields [`sum_outbound_balance`] looks at. #[derive(Debug, Clone)] pub(crate) struct ChannelSnapshot { pub counterparty: PublicKey, @@ -91,13 +121,44 @@ impl From<&ChannelDetails> for ChannelSnapshot { /// 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( +/// Top-level entry point. Picks an [`EstimationStrategy`] for +/// `dest` and folds the result into a [`MaxSendableEstimate`]. +/// `find_route` is the only effect; everything else is pure +/// dispatch. See the module-level coverage table. +pub(crate) fn compute_estimate( + dest: Option<&PaymentInstructions>, channels: &[ChannelSnapshot], lsp_pubkey: &PublicKey, fee_buffer_bps: u16, fee_buffer_floor_sats: u64, ) -> Result { + cfg: &MaxSendableConfig, + find_route: F, +) -> Result +where + F: FnOnce(RouteParameters) -> Result, +{ let balance_msat = sum_outbound_balance(channels, lsp_pubkey)?; - Ok(subtract_fee_buffer(balance_msat, cfg)) + match dest { + None => Ok(subtract_fee_buffer(balance_msat, cfg)), + Some(PaymentInstructions::FixedAmount(fixed)) => Err(MaxSendableError::FixedAmount { + // `None` here means non-BTC pricing; report zero rather + // than guess an FX rate. + amount_msat: fixed + .ln_payment_amount() + .map(|a| a.milli_sats()) + .unwrap_or(0), + }), + Some(PaymentInstructions::ConfigurableAmount(inst)) => match pick_strategy(inst.methods())? { + EstimationStrategy::Buffer => Ok(subtract_fee_buffer(balance_msat, cfg)), + EstimationStrategy::FromRoute(payment_params) => { + let route_params = + RouteParameters::from_payment_params_and_value(payment_params, balance_msat); + let route = find_route(route_params).map_err(MaxSendableError::RoutingFailure)?; + Ok(estimate_from_route(balance_msat, &route, cfg)) + } + }, + } } /// Subtract the configured fee buffer from a known outbound balance. @@ -115,6 +176,23 @@ fn subtract_fee_buffer(balance_msat: u64, cfg: &MaxSendableConfig) -> MaxSendabl } } +/// Estimate the max sendable amount from route fees. The multiplier +/// scales with the chosen route's cost so retries can pick a +/// meaningfully more expensive path. +fn estimate_from_route( + balance_msat: u64, + route: &Route, + cfg: &MaxSendableConfig, +) -> MaxSendableEstimate { + let total_fees_msat: u64 = route.paths.iter().map(|p| p.fee_msat()).sum(); + let fee_budget_msat = + ((total_fees_msat as u128) * (cfg.route_retry_fee_multiplier_bps as u128) / 10_000) as u64; + MaxSendableEstimate { + amount_msat: balance_msat.saturating_sub(fee_budget_msat), + fee_budget_msat, + } +} + /// Sum `next_outbound_htlc_limit_msat` over usable channels with /// the LSP. The `Option` accumulator distinguishes "no channel /// matched" (None → `NoUsableChannel`) from "channel(s) matched, @@ -149,9 +227,57 @@ fn sum_outbound_balance( .ok_or(MaxSendableError::NoUsableChannel) } +/// How [`compute_estimate`] should price the chosen Lightning +/// destination. +#[derive(Debug)] +enum EstimationStrategy { + /// Ask `find_route` with these params; subtract the real fees. + FromRoute(PaymentParameters), + /// Fall back to the simple buffer for destinations that cannot + /// yet use route-based estimation. + Buffer, +} + +/// Pick the first Lightning method and decide how to price it. +/// On-chain and unresolved LNURL methods are skipped; an empty +/// result yields `NoLightningMethod`. +/// +/// BOLT11 round-trips through bech32: bitcoin-payment-instructions +/// pulls upstream rust-lightning's `Bolt11Invoice`, ldk-node pulls +/// the moneydevkit fork — distinct types in the dep graph, +/// identical wire format. A re-parse failure falls back to Buffer +/// rather than panicking. +fn pick_strategy<'a, I>(methods: I) -> Result +where + I: IntoIterator>, +{ + for method in methods { + match method { + PossiblyResolvedPaymentMethod::Resolved(PaymentMethod::LightningBolt11(inv)) => { + return Ok(match inv.to_string().parse::() { + Ok(ldk_inv) => { + EstimationStrategy::FromRoute(PaymentParameters::from_bolt11_invoice(&ldk_inv)) + } + Err(_) => EstimationStrategy::Buffer, + }); + } + PossiblyResolvedPaymentMethod::Resolved(PaymentMethod::LightningBolt12(_)) => { + return Ok(EstimationStrategy::Buffer); + } + PossiblyResolvedPaymentMethod::LNURLPay { .. } => { + return Ok(EstimationStrategy::Buffer); + } + _ => continue, + } + } + Err(MaxSendableError::NoLightningMethod) +} + #[cfg(test)] mod tests { use super::*; + use ldk_node::lightning::routing::router::{Path, RouteHop}; + use ldk_node::lightning::types::features::{ChannelFeatures, NodeFeatures}; use std::str::FromStr; fn lsp() -> PublicKey { @@ -172,12 +298,51 @@ mod tests { } } + /// Run the public `compute_estimate` with `dest = None`. The + /// closure panics if invoked — the None path must never route. + fn buffer_estimate( + chans: &[ChannelSnapshot], + lsp: &PublicKey, + cfg: &MaxSendableConfig, + ) -> Result { + compute_estimate(None, chans, lsp, cfg, |_| { + panic!("None dest must not invoke find_route") + }) + } + + /// Build a `Route` whose paths carry the given per-hop fee_msat + /// values. `Path::fee_msat()` excludes the last hop (which + /// carries the payment amount, not a fee), so each path needs + /// at least one trailing "amount" hop. + fn make_route(paths_fees: &[&[u64]]) -> Route { + let hop = |fee_msat| RouteHop { + pubkey: lsp(), + node_features: NodeFeatures::empty(), + short_channel_id: 0, + channel_features: ChannelFeatures::empty(), + fee_msat, + cltv_expiry_delta: 0, + maybe_announced_channel: false, + }; + Route { + paths: paths_fees + .iter() + .map(|hops| Path { + hops: hops.iter().map(|&f| hop(f)).collect(), + blinded_tail: None, + }) + .collect(), + route_params: None, + } + } + #[test] fn no_usable_channel_when_empty() { let lsp = lsp(); let res = compute_estimate(&[], &lsp, BPS, FLOOR); let res = compute_estimate(&[], &lsp, &MaxSendableConfig::default()); let res = compute_estimate(&[], &lsp(), &MaxSendableConfig::default()); + let res = buffer_estimate(&[], &lsp(), &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } @@ -186,6 +351,8 @@ mod tests { let lsp = lsp(); let chans = [snap(other_peer(), true, 100_000_000)]; let res = compute_estimate(&chans, &lsp, BPS, FLOOR); + let res = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()); + let res = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } @@ -195,6 +362,8 @@ mod tests { let lsp = lsp(); let chans = [snap(lsp, false, 100_000_000)]; let res = compute_estimate(&chans, &lsp, BPS, FLOOR); + let res = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()); + let res = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } @@ -205,7 +374,7 @@ mod tests { let chans = [snap(lsp, true, 5_000)]; // 5 sats let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); let chans = [snap(lsp, true, 5_000)]; - let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.amount_msat, 0); assert_eq!(est.fee_budget_msat, 10_000); // 10-sat floor } @@ -215,6 +384,8 @@ mod tests { let lsp = lsp(); let chans = [snap(lsp, true, 10_000)]; let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); + let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.amount_msat, 0); assert_eq!(est.fee_budget_msat, 10_000); } @@ -228,7 +399,7 @@ mod tests { assert_eq!(est.fee_budget_msat, 1_000_000); // 1000 sats assert_eq!(est.amount_msat, 99_000_000); // 99k sats let chans = [snap(lsp, true, 100_000_000)]; - let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.fee_budget_msat, 1_000_000); assert_eq!(est.amount_msat, 99_000_000); } @@ -242,7 +413,7 @@ mod tests { assert_eq!(est.fee_budget_msat, 10_000); // 10-sat floor assert_eq!(est.amount_msat, 490_000); // 490 sats let chans = [snap(lsp, true, 500_000)]; - let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.fee_budget_msat, 10_000); assert_eq!(est.amount_msat, 490_000); } @@ -258,7 +429,7 @@ mod tests { let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); assert_eq!(est.fee_budget_msat, 800_000); // 1% of 80k sats let chans = [snap(lsp, true, 50_000_000), snap(lsp, true, 30_000_000)]; - let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.fee_budget_msat, 800_000); assert_eq!(est.amount_msat, 79_200_000); } @@ -276,6 +447,7 @@ mod tests { let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); assert_eq!(est.fee_budget_msat, 100_000); // 1% of 10k sats let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.fee_budget_msat, 100_000); assert_eq!(est.amount_msat, 9_900_000); } @@ -292,8 +464,53 @@ mod tests { fee_buffer_floor_sats: 50, ..MaxSendableConfig::default() }; - let est = compute_estimate(&chans, &lsp, &cfg).unwrap(); + let est = buffer_estimate(&chans, &lsp, &cfg).unwrap(); assert_eq!(est.fee_budget_msat, 20_000_000); // 2% of 1M sats assert_eq!(est.amount_msat, 980_000_000); } + + #[test] + fn estimate_from_route_applies_default_multiplier() { + // 3 hops, fees 50M + 30M, last hop = amount. + // total_fees = 80_000_000, 1.1x multiplier = 88_000_000. + let cfg = MaxSendableConfig::default(); + let route = make_route(&[&[50_000_000, 30_000_000, 1_000_000_000]]); + let est = estimate_from_route(2_000_000_000, &route, &cfg); + assert_eq!(est.fee_budget_msat, 88_000_000); + } + + #[test] + fn estimate_from_route_mpp_sums_across_paths() { + // 2 paths × 2 hops. Fees per path = 1_000 and 2_000. + // total_fees = 3_000, 1.1x multiplier = 3_300. + let cfg = MaxSendableConfig::default(); + let route = make_route(&[&[1_000, 500_000], &[2_000, 500_000]]); + let est = estimate_from_route(10_000_000, &route, &cfg); + assert_eq!(est.fee_budget_msat, 3_300); + assert_eq!(est.amount_msat, 9_996_700); + } + + #[test] + fn estimate_from_route_saturates_when_balance_below_fees() { + // Fee budget exceeds balance — amount clamps to 0 rather + // than underflowing. + let cfg = MaxSendableConfig::default(); + let route = make_route(&[&[10_000, 1_000_000]]); + let est = estimate_from_route(100, &route, &cfg); + assert_eq!(est.amount_msat, 0); + assert!(est.fee_budget_msat > 100); + } + + #[test] + fn estimate_from_route_honours_multiplier_override() { + // total_fees = 1_000_000. With multiplier=30_000 (3.0x): + // fee_budget = 3_000_000. + let cfg = MaxSendableConfig { + route_retry_fee_multiplier_bps: 30_000, + ..MaxSendableConfig::default() + }; + let route = make_route(&[&[1_000_000, 100_000_000]]); + let est = estimate_from_route(200_000_000, &route, &cfg); + assert_eq!(est.fee_budget_msat, 3_000_000); + } } From bc19fbe3a7e4cdddfbc292c01e42310bc88d0a11 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 20 May 2026 07:34:28 -0700 Subject: [PATCH 4/5] Expose destination param on getMaxSendable Add an optional `destination` arg to `MdkNode#getMaxSendable` so JS callers can ask "how much can I send to this specific payee right now" and get a fee budget grounded in a real `find_route` call rather than the 1% buffer fallback. When supplied, the destination is parsed through the same HTTPHrnResolver + current-thread runtime path used by `pay`, then threaded into compute_estimate. The four MaxSendableError variants map onto the napi surface as: - NoUsableChannel -> Ok(None) Preserves the existing null contract so UIs do not have to re-handle the "node still booting" case. - FixedAmount -> InvalidArg Carries the dictated amount in the message so callers can surface it without re-parsing. - NoLightningMethod -> InvalidArg - RoutingFailure -> GenericFailure - parse failure -> InvalidArg The regenerated index.d.ts/index.js carry the new optional arg, the routeRetryFeeMultiplierBps field added two commits back, and a pile of unrelated whitespace churn from napi-rs's formatter disagreeing with whatever generated the previously committed files. Folding the formatter noise into this commit keeps the diff against the next regen empty. --- index.d.ts | 48 +++++++++++++++++++----------------------- index.js | 52 +++++++++++++++++++++++++++++++++------------ src/lib.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 114 insertions(+), 48 deletions(-) diff --git a/index.d.ts b/index.d.ts index a88e211..1b3dbcf 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,10 +3,7 @@ /* auto-generated by NAPI-RS */ -export declare function setLogListener( - callback?: (...args: any[]) => any | undefined | null, - minLevel?: string | undefined | null, -): void +export declare function setLogListener(callback?: (...args: any[]) => any | undefined | null, minLevel?: string | undefined | null): void export declare function generateMnemonic(): string /** * Derive the node public key from a mnemonic and network without building the full node. @@ -64,6 +61,12 @@ export interface MaxSendableConfig { feeBufferBps?: number /** Absolute lower bound on the buffer, in sats. Default: 10. */ feeBufferFloorSats?: number + /** + * Fee budget multiplier in basis points of the cheapest route's + * total fees (10_000 = 1.0x, 20_000 = 2.0x). Reserved so a send has + * headroom to retry along a costlier path. Default: 11_000 (1.1x). + */ + routeRetryFeeMultiplierBps?: number } export interface PaymentMetadata { bolt11: string @@ -184,6 +187,12 @@ export declare class MdkNode { * Best-effort estimate of the largest amount that can flow out over * Lightning right now, with routing-fee headroom subtracted. * + * When `destination` is supplied, the fee budget comes from a real + * `find_route` against that destination (currently zero-amount BOLT11 + * only). BOLT12, LNURL-pay, and HRN destinations parse but fall back + * to the destination-agnostic buffer until invoice/HRN resolution + * moves into the estimator. + * * Returns `null` when no usable LSP channel exists. `Some(amountMsat: 0)` * is distinct from `null` — it means a channel exists but the balance * is fully consumed by the fee buffer (dust). Consumers should never @@ -191,9 +200,13 @@ export declare class MdkNode { * project from the same `list_channels()` snapshot inside a single * call. * + * Throws `InvalidArg` when the destination cannot be parsed, dictates + * its own amount (fixed-amount BOLT11/BOLT12), or carries no Lightning + * method. Throws `GenericFailure` when routing fails outright. + * * Read-only; safe to call whether or not the node has been started. */ - getMaxSendable(): MaxSendableEstimate | null + getMaxSendable(destination?: string | undefined | null): MaxSendableEstimate | null /** * Manually sync the RGS snapshot. * @@ -213,18 +226,9 @@ export declare class MdkNode { * Use this when the node is already running via start_receiving(). */ getVariableAmountJitInvoiceWhileRunning(description: string, expirySecs: number): PaymentMetadata - getInvoiceWithScid( - humanReadableScid: string, - amount: number, - description: string, - expirySecs: number, - ): PaymentMetadata + getInvoiceWithScid(humanReadableScid: string, amount: number, description: string, expirySecs: number): PaymentMetadata getVariableAmountJitInvoice(description: string, expirySecs: number): PaymentMetadata - getVariableAmountJitInvoiceWithScid( - humanReadableScid: string, - description: string, - expirySecs: number, - ): PaymentMetadata + getVariableAmountJitInvoiceWithScid(humanReadableScid: string, description: string, expirySecs: number): PaymentMetadata /** * Get a BOLT12 offer for receiving via LSPS4 JIT channel. * Use this when the node is already running via start_receiving(). @@ -252,11 +256,7 @@ export declare class MdkNode { * For fixed-amount BOLT11 invoices, amount_msat can be omitted (the invoice amount is used). * For variable-amount destinations, amount_msat is required. */ - pay( - destination: string, - amountMsat?: number | undefined | null, - waitForPaymentSecs?: number | undefined | null, - ): PaymentResult + pay(destination: string, amountMsat?: number | undefined | null, waitForPaymentSecs?: number | undefined | null): PaymentResult /** * Unified payment method that auto-detects the destination type. * Use this when the node is already running via start_receiving(). @@ -270,9 +270,5 @@ export declare class MdkNode { * For fixed-amount BOLT11 invoices, amount_msat can be omitted (the invoice amount is used). * For variable-amount destinations, amount_msat is required. */ - payWhileRunning( - destination: string, - amountMsat?: number | undefined | null, - waitForPaymentSecs?: number | undefined | null, - ): PaymentResult + payWhileRunning(destination: string, amountMsat?: number | undefined | null, waitForPaymentSecs?: number | undefined | null): PaymentResult } diff --git a/index.js b/index.js index 429d702..c25c576 100644 --- a/index.js +++ b/index.js @@ -62,7 +62,9 @@ switch (platform) { case 'win32': switch (arch) { case 'x64': - localFileExisted = existsSync(join(__dirname, 'lightning-js.win32-x64-msvc.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.win32-x64-msvc.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.win32-x64-msvc.node') @@ -74,7 +76,9 @@ switch (platform) { } break case 'ia32': - localFileExisted = existsSync(join(__dirname, 'lightning-js.win32-ia32-msvc.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.win32-ia32-msvc.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.win32-ia32-msvc.node') @@ -86,7 +90,9 @@ switch (platform) { } break case 'arm64': - localFileExisted = existsSync(join(__dirname, 'lightning-js.win32-arm64-msvc.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.win32-arm64-msvc.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.win32-arm64-msvc.node') @@ -125,7 +131,9 @@ switch (platform) { } break case 'arm64': - localFileExisted = existsSync(join(__dirname, 'lightning-js.darwin-arm64.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.darwin-arm64.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.darwin-arm64.node') @@ -159,7 +167,9 @@ switch (platform) { switch (arch) { case 'x64': if (isMusl()) { - localFileExisted = existsSync(join(__dirname, 'lightning-js.linux-x64-musl.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.linux-x64-musl.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.linux-x64-musl.node') @@ -170,7 +180,9 @@ switch (platform) { loadError = e } } else { - localFileExisted = existsSync(join(__dirname, 'lightning-js.linux-x64-gnu.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.linux-x64-gnu.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.linux-x64-gnu.node') @@ -184,7 +196,9 @@ switch (platform) { break case 'arm64': if (isMusl()) { - localFileExisted = existsSync(join(__dirname, 'lightning-js.linux-arm64-musl.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.linux-arm64-musl.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.linux-arm64-musl.node') @@ -195,7 +209,9 @@ switch (platform) { loadError = e } } else { - localFileExisted = existsSync(join(__dirname, 'lightning-js.linux-arm64-gnu.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.linux-arm64-gnu.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.linux-arm64-gnu.node') @@ -209,7 +225,9 @@ switch (platform) { break case 'arm': if (isMusl()) { - localFileExisted = existsSync(join(__dirname, 'lightning-js.linux-arm-musleabihf.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.linux-arm-musleabihf.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.linux-arm-musleabihf.node') @@ -220,7 +238,9 @@ switch (platform) { loadError = e } } else { - localFileExisted = existsSync(join(__dirname, 'lightning-js.linux-arm-gnueabihf.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.linux-arm-gnueabihf.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.linux-arm-gnueabihf.node') @@ -234,7 +254,9 @@ switch (platform) { break case 'riscv64': if (isMusl()) { - localFileExisted = existsSync(join(__dirname, 'lightning-js.linux-riscv64-musl.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.linux-riscv64-musl.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.linux-riscv64-musl.node') @@ -245,7 +267,9 @@ switch (platform) { loadError = e } } else { - localFileExisted = existsSync(join(__dirname, 'lightning-js.linux-riscv64-gnu.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.linux-riscv64-gnu.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.linux-riscv64-gnu.node') @@ -258,7 +282,9 @@ switch (platform) { } break case 's390x': - localFileExisted = existsSync(join(__dirname, 'lightning-js.linux-s390x-gnu.node')) + localFileExisted = existsSync( + join(__dirname, 'lightning-js.linux-s390x-gnu.node') + ) try { if (localFileExisted) { nativeBinding = require('./lightning-js.linux-s390x-gnu.node') diff --git a/src/lib.rs b/src/lib.rs index 5a1f54a..23e5b5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -944,6 +944,9 @@ impl MdkNode { /// Best-effort estimate of the largest amount that can flow out over /// Lightning right now, with routing-fee headroom subtracted. /// + /// When `destination` is supplied, the fee budget comes from a real + /// `find_route` against that destination. + /// /// Returns `null` when no usable LSP channel exists. `Some(amountMsat: 0)` /// is distinct from `null` — it means a channel exists but the balance /// is fully consumed by the fee buffer (dust). Consumers should never @@ -951,27 +954,68 @@ impl MdkNode { /// project from the same `list_channels()` snapshot inside a single /// call. /// + /// Throws `InvalidArg` when the destination cannot be parsed, dictates + /// its own amount (fixed-amount BOLT11/BOLT12), or carries no Lightning + /// method. Throws `GenericFailure` when routing fails outright. + /// /// Read-only; safe to call whether or not the node has been started. #[napi] - pub fn get_max_sendable(&self) -> Option { + pub fn get_max_sendable( + &self, + destination: Option, + ) -> napi::Result> { + let parsed = destination + .as_deref() + .map(|d| { + let resolver = HTTPHrnResolver::new(); + let runtime = create_current_thread_runtime()?; + runtime + .block_on(PaymentInstructions::parse(d, self.network, &resolver, true)) + .map_err(|err| { + napi::Error::new( + Status::InvalidArg, + format!("failed to parse destination: {err:?}"), + ) + }) + }) + .transpose()?; + let snaps: Vec = self .node() .list_channels() .iter() .map(max_sendable::ChannelSnapshot::from) .collect(); - max_sendable::compute_estimate( - None, + + let result = max_sendable::compute_estimate( + parsed.as_ref(), &snaps, &self.lsp_pubkey, &self.max_sendable_cfg, |rp| self.node().find_route(rp).map_err(|e| format!("{e:?}")), - ) - .ok() - .map(|e| MaxSendableEstimate { - amount_msat: u64_to_i64(e.amount_msat), - fee_budget_msat: u64_to_i64(e.fee_budget_msat), - }) + ); + + match result { + Ok(e) => Ok(Some(MaxSendableEstimate { + amount_msat: u64_to_i64(e.amount_msat), + fee_budget_msat: u64_to_i64(e.fee_budget_msat), + })), + // Preserve the existing "channel not ready" → null contract so + // JS UIs don't have to re-handle the most common transient case. + Err(max_sendable::MaxSendableError::NoUsableChannel) => Ok(None), + Err(max_sendable::MaxSendableError::FixedAmount { amount_msat }) => Err(napi::Error::new( + Status::InvalidArg, + format!("destination is fixed-amount ({amount_msat}msat)"), + )), + Err(max_sendable::MaxSendableError::NoLightningMethod) => Err(napi::Error::new( + Status::InvalidArg, + "destination has no lightning method", + )), + Err(max_sendable::MaxSendableError::RoutingFailure(msg)) => Err(napi::Error::new( + Status::GenericFailure, + format!("no route: {msg}"), + )), + } } /// Manually sync the RGS snapshot. From ea5639ca5d7579db971463c86f9eb00b5cb33df1 Mon Sep 17 00:00:00 2001 From: amackillop Date: Fri, 22 May 2026 06:35:38 -0700 Subject: [PATCH 5/5] Fix broken merge in max-sendable destination work src/max_sendable.rs was left in a state that never compiled due to a broken merge. Fixed. The getMaxSendable doc claimed the call is safe on a stopped node. That is only true with no destination. When a destination is supplied the call hits Node::find_route, which returns NotRunning on a stopped node, so the blanket sentence is wrong. Removed. One unrelated clippy lint (len() > 0 in the offer-paths test) was masked while max_sendable.rs would not compile. Fixed so the crate builds clean under -D warnings. The codex bot's suggestion to route against `balance - fees` instead of the full outbound balance is left alone; the single-iteration approximation was explicitly accepted in the PR discussion. --- src/lib.rs | 4 +-- src/max_sendable.rs | 66 +++++++-------------------------------------- 2 files changed, 10 insertions(+), 60 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 23e5b5b..c3d76dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -957,8 +957,6 @@ impl MdkNode { /// Throws `InvalidArg` when the destination cannot be parsed, dictates /// its own amount (fixed-amount BOLT11/BOLT12), or carries no Lightning /// method. Throws `GenericFailure` when routing fails outright. - /// - /// Read-only; safe to call whether or not the node has been started. #[napi] pub fn get_max_sendable( &self, @@ -1899,7 +1897,7 @@ mod tests { } assert!( - offer.paths().len() > 0, + !offer.paths().is_empty(), "Offer should have at least one path!" ); } diff --git a/src/max_sendable.rs b/src/max_sendable.rs index 284de49..450586d 100644 --- a/src/max_sendable.rs +++ b/src/max_sendable.rs @@ -113,25 +113,19 @@ impl From<&ChannelDetails> for ChannelSnapshot { } } -/// Given a snapshot of channels, the LSP pubkey, and a buffer -/// configuration, return the estimate. +/// Top-level entry point. Picks an [`EstimationStrategy`] for +/// `dest` and folds the result into a [`MaxSendableEstimate`]. +/// `find_route` is the only effect; everything else is pure +/// dispatch. See the module-level coverage table. /// /// `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( -/// Top-level entry point. Picks an [`EstimationStrategy`] for -/// `dest` and folds the result into a [`MaxSendableEstimate`]. -/// `find_route` is the only effect; everything else is pure -/// dispatch. See the module-level coverage table. pub(crate) fn compute_estimate( dest: Option<&PaymentInstructions>, channels: &[ChannelSnapshot], lsp_pubkey: &PublicKey, - fee_buffer_bps: u16, - fee_buffer_floor_sats: u64, -) -> Result { cfg: &MaxSendableConfig, find_route: F, ) -> Result @@ -211,19 +205,6 @@ fn sum_outbound_balance( .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) * (fee_buffer_bps as u128) / 10_000) as u64; - let floor_buffer = 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, - }) .ok_or(MaxSendableError::NoUsableChannel) } @@ -338,10 +319,6 @@ mod tests { #[test] fn no_usable_channel_when_empty() { - let lsp = lsp(); - let res = compute_estimate(&[], &lsp, BPS, FLOOR); - let res = compute_estimate(&[], &lsp, &MaxSendableConfig::default()); - let res = compute_estimate(&[], &lsp(), &MaxSendableConfig::default()); let res = buffer_estimate(&[], &lsp(), &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } @@ -350,8 +327,6 @@ mod tests { 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, BPS, FLOOR); - let res = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()); let res = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } @@ -361,8 +336,6 @@ mod tests { // Mid-open/splice — distinct from "balance is zero". let lsp = lsp(); let chans = [snap(lsp, false, 100_000_000)]; - let res = compute_estimate(&chans, &lsp, BPS, FLOOR); - let res = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()); let res = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } @@ -372,8 +345,6 @@ mod tests { // 5 sats < 10-sat floor → buffer wins, amount saturates to 0. let lsp = lsp(); let chans = [snap(lsp, true, 5_000)]; // 5 sats - let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); - let chans = [snap(lsp, true, 5_000)]; let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.amount_msat, 0); assert_eq!(est.fee_budget_msat, 10_000); // 10-sat floor @@ -383,8 +354,6 @@ mod tests { fn balance_exactly_equals_buffer_returns_zero() { let lsp = lsp(); let chans = [snap(lsp, true, 10_000)]; - let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); - let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.amount_msat, 0); assert_eq!(est.fee_budget_msat, 10_000); @@ -395,13 +364,9 @@ 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, BPS, FLOOR).unwrap(); + let est = buffer_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 - let chans = [snap(lsp, true, 100_000_000)]; - let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); - assert_eq!(est.fee_budget_msat, 1_000_000); - assert_eq!(est.amount_msat, 99_000_000); } #[test] @@ -409,13 +374,9 @@ 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, BPS, FLOOR).unwrap(); + let est = buffer_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 - let chans = [snap(lsp, true, 500_000)]; - let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); - assert_eq!(est.fee_budget_msat, 10_000); - assert_eq!(est.amount_msat, 490_000); } #[test] @@ -426,11 +387,8 @@ mod tests { snap(lsp, true, 50_000_000), // 50k sats snap(lsp, true, 30_000_000), // 30k sats ]; - let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); - assert_eq!(est.fee_budget_msat, 800_000); // 1% of 80k sats - let chans = [snap(lsp, true, 50_000_000), snap(lsp, true, 30_000_000)]; let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); - assert_eq!(est.fee_budget_msat, 800_000); + assert_eq!(est.fee_budget_msat, 800_000); // 1% of 80k sats assert_eq!(est.amount_msat, 79_200_000); } @@ -444,11 +402,8 @@ mod tests { snap(other, true, 50_000_000), snap(lsp, false, 100_000_000), ]; - let est = compute_estimate(&chans, &lsp, BPS, FLOOR).unwrap(); - assert_eq!(est.fee_budget_msat, 100_000); // 1% of 10k sats - let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); - assert_eq!(est.fee_budget_msat, 100_000); + assert_eq!(est.fee_budget_msat, 100_000); // 1% of 10k sats assert_eq!(est.amount_msat, 9_900_000); } @@ -456,16 +411,13 @@ mod tests { fn overrides_take_effect() { let lsp = lsp(); let chans = [snap(lsp, true, 1_000_000_000)]; // 1M sats - let est = compute_estimate(&chans, &lsp, 200, 50).unwrap(); - assert_eq!(est.fee_budget_msat, 20_000_000); // 2% of 1M sats = 20k sats - let chans = [snap(lsp, true, 1_000_000_000)]; let cfg = MaxSendableConfig { fee_buffer_bps: 200, fee_buffer_floor_sats: 50, ..MaxSendableConfig::default() }; let est = buffer_estimate(&chans, &lsp, &cfg).unwrap(); - assert_eq!(est.fee_budget_msat, 20_000_000); // 2% of 1M sats + assert_eq!(est.fee_budget_msat, 20_000_000); // 2% of 1M sats = 20k sats assert_eq!(est.amount_msat, 980_000_000); }