diff --git a/index.d.ts b/index.d.ts index 1399fad..a88e211 100644 --- a/index.d.ts +++ b/index.d.ts @@ -41,6 +41,7 @@ export interface MdkNodeOptions { lspAddress: string scoringParamOverrides?: ScoringParamOverrides splice?: SpliceConfig + maxSendable?: MaxSendableConfig } /** * Configuration for the auto-splice manager. The manager wakes up every @@ -53,6 +54,17 @@ export interface SpliceConfig { /** Poll interval in seconds. Default: 30. */ pollIntervalSecs?: number } +/** + * 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. + */ +export interface MaxSendableConfig { + /** Percentage buffer in basis points (1 bps = 0.01 %). Default: 100 (1 %). */ + feeBufferBps?: number + /** Absolute lower bound on the buffer, in sats. Default: 10. */ + feeBufferFloorSats?: number +} export interface PaymentMetadata { bolt11: string paymentHash: string @@ -102,6 +114,22 @@ export interface NodeChannel { isUsable: boolean isPublic: boolean } +/** + * Best-effort estimate of the largest amount that can flow out over + * Lightning right now, with routing-fee headroom subtracted. + */ +export interface MaxSendableEstimate { + /** + * Amount to surface to the payer as "max sendable", in msat. Zero + * when the balance is fully consumed by the buffer (dust). + */ + amountMsat: number + /** + * The buffer subtracted from the raw outbound liquidity to reach + * `amount_msat`. Doubles as a hint for `max_total_routing_fee_msat`. + */ + feeBudgetMsat: number +} export declare class MdkNode { constructor(options: MdkNodeOptions) /** @@ -152,6 +180,20 @@ export declare class MdkNode { */ getBalanceWhileRunning(): number listChannels(): Array + /** + * Best-effort estimate of the largest amount that can flow out over + * Lightning right now, with routing-fee headroom subtracted. + * + * 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 + * see a positive `getBalance()` paired with a `null` here: both + * project from the same `list_channels()` snapshot inside a single + * call. + * + * Read-only; safe to call whether or not the node has been started. + */ + getMaxSendable(): MaxSendableEstimate | null /** * Manually sync the RGS snapshot. * diff --git a/src/lib.rs b/src/lib.rs index 18488ed..6e1d90c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,6 +58,7 @@ use tokio_util::sync::CancellationToken; #[macro_use] extern crate napi_derive; +mod max_sendable; mod splice_manager; /// Polling interval for event loops and state checks. @@ -301,6 +302,7 @@ pub struct MdkNodeOptions { pub lsp_address: String, pub scoring_param_overrides: Option, pub splice: Option, + pub max_sendable: Option, } /// Configuration for the auto-splice manager. The manager wakes up every @@ -340,6 +342,43 @@ 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. +#[napi(object)] +#[derive(Default)] +pub struct MaxSendableConfig { + /// Percentage buffer in basis points (1 bps = 0.01 %). Default: 100 (1 %). + pub fee_buffer_bps: Option, + /// Absolute lower bound on the buffer, in sats. Default: 10. + pub fee_buffer_floor_sats: 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) + } +} + #[napi(object)] pub struct PaymentMetadata { pub bolt11: String, @@ -405,6 +444,18 @@ pub struct NodeChannel { pub is_public: bool, } +/// Best-effort estimate of the largest amount that can flow out over +/// Lightning right now, with routing-fee headroom subtracted. +#[napi(object)] +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: i64, + /// The buffer subtracted from the raw outbound liquidity to reach + /// `amount_msat`. Doubles as a hint for `max_total_routing_fee_msat`. + pub fee_budget_msat: i64, +} + #[napi] pub struct MdkNode { node: Option>, @@ -413,6 +464,7 @@ pub struct MdkNode { /// channels by counterparty. lsp_pubkey: PublicKey, splice_cfg: ResolvedSpliceConfig, + max_sendable_cfg: MaxSendableConfig, /// One-worker tokio runtime dedicated to the splice manager. splice_runtime: Runtime, /// `Some` while a splice manager is running, `None` otherwise. @@ -526,6 +578,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(); // One self-driving worker is enough; the manager sleeps between ticks. let splice_runtime = tokio::runtime::Builder::new_multi_thread() @@ -540,6 +593,7 @@ impl MdkNode { network, lsp_pubkey: lsp_node_id, splice_cfg, + max_sendable_cfg, splice_runtime, splice_task: Mutex::new(None), }) @@ -883,6 +937,34 @@ impl MdkNode { .collect() } + /// Best-effort estimate of the largest amount that can flow out over + /// Lightning right now, with routing-fee headroom subtracted. + /// + /// 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 + /// see a positive `getBalance()` paired with a `null` here: both + /// project from the same `list_channels()` snapshot inside a single + /// call. + /// + /// Read-only; safe to call whether or not the node has been started. + #[napi] + pub fn get_max_sendable(&self) -> Option { + let snaps: Vec = self + .node() + .list_channels() + .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) + .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. /// /// If `do_full_sync` is true, the RGS snapshot will be updated from scratch. Otherwise, the diff --git a/src/max_sendable.rs b/src/max_sendable.rs new file mode 100644 index 0000000..420ab82 --- /dev/null +++ b/src/max_sendable.rs @@ -0,0 +1,229 @@ +//! Estimator for the largest amount that can be sent over Lightning +//! out of mdk's LSP channel(s), with routing fees subtracted. +//! +//! v0 is destination-agnostic: it subtracts a configurable percentage +//! buffer (default 1%, 10-sat floor) from the sum of usable LSP +//! channels' `next_outbound_htlc_limit_msat`. v1 will replace the +//! buffer with a real `Router::find_route` + per-hop fee inversion. +//! The [`compute_estimate`] function is the seam — the accessor that +//! calls it stays put across v0→v1. + +use ldk_node::ChannelDetails; +use ldk_node::bitcoin::secp256k1::PublicKey; + +/// 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(crate) struct MaxSendableEstimate { + /// Amount to surface to the payer as "max sendable", in msat. + /// Zero when the balance is fully consumed by the buffer (dust). + pub amount_msat: u64, + /// The buffer subtracted from the raw outbound liquidity to reach + /// `amount_msat`. Doubles as a hint for `max_total_routing_fee_msat`. + pub fee_budget_msat: u64, +} + +#[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)`). + NoUsableChannel, +} + +/// Minimal projection of `ldk_node::ChannelDetails` carrying only the +/// fields [`compute_estimate`] looks at. +#[derive(Debug, Clone)] +pub(crate) struct ChannelSnapshot { + pub counterparty: PublicKey, + pub is_usable: bool, + pub next_outbound_htlc_limit_msat: u64, +} + +impl From<&ChannelDetails> for ChannelSnapshot { + fn from(c: &ChannelDetails) -> Self { + Self { + counterparty: c.counterparty_node_id, + is_usable: c.is_usable, + next_outbound_htlc_limit_msat: c.next_outbound_htlc_limit_msat, + } + } +} + +/// Given a snapshot of channels, the LSP pubkey, and a buffer +/// configuration, return the estimate. +/// +/// `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, + 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 + .iter() + .filter(|c| c.counterparty == *lsp_pubkey && c.is_usable) + .fold(None::, |acc, c| { + Some( + acc + .unwrap_or(0) + .saturating_add(c.next_outbound_htlc_limit_msat), + ) + }) + .ok_or(MaxSendableError::NoUsableChannel)?; + + // u128 intermediate dodges overflow at the percentage step. ppm + // basis-points × u64 msat fits in u128 trivially, and the divide + // brings it back into u64 range. + let pct_buffer = ((balance_msat as u128) * (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, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + const BPS: u16 = 100; + const FLOOR: u64 = 10; + + 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, BPS, FLOOR); + 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, BPS, FLOOR); + assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); + } + + #[test] + fn no_usable_channel_when_lsp_channel_unusable() { + // Channel exists with the LSP but is mid-open or mid-splice + // — explicitly distinct from "balance is zero". + let lsp = lsp(); + let chans = [snap(lsp, false, 100_000_000)]; + let res = compute_estimate(&chans, &lsp, BPS, FLOOR); + assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); + } + + #[test] + fn dust_balance_below_floor_returns_zero() { + // 5 sats of outbound. Floor buffer is 10 sats → buffer wins, + // amount saturates to zero. The estimate is "you have + // liquidity, but it can't cover even the floor fee" — not an + // error. + let lsp = lsp(); + let chans = [snap(lsp, true, 5_000)]; // 5 sats + let est = compute_estimate(&chans, &lsp, BPS, FLOOR).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(); + 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, BPS, FLOOR).unwrap(); + assert_eq!(est.fee_budget_msat, 1_000_000); // 1000 sats + assert_eq!(est.amount_msat, 99_000_000); // 99k sats + } + + #[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, BPS, FLOOR).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, BPS, FLOOR).unwrap(); + 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, BPS, FLOOR).unwrap(); + 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 est = compute_estimate(&chans, &lsp, 200, 50).unwrap(); + assert_eq!(est.fee_budget_msat, 20_000_000); // 2% of 1M sats = 20k sats + assert_eq!(est.amount_msat, 980_000_000); + } +}