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"] } 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 6e1d90c..c3d76dd 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() @@ -940,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 @@ -947,22 +954,66 @@ impl MdkNode { /// project from the same `list_channels()` snapshot inside a single /// call. /// - /// Read-only; safe to call whether or not the node has been started. + /// 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. #[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(); - let (bps, floor_sats) = self.max_sendable_cfg.resolve(); - max_sendable::compute_estimate(&snaps, &self.lsp_pubkey, bps, floor_sats) - .ok() - .map(|e| MaxSendableEstimate { + + 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:?}")), + ); + + 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. @@ -1846,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 420ab82..450586d 100644 --- a/src/max_sendable.rs +++ b/src/max_sendable.rs @@ -1,15 +1,70 @@ //! 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 +/// 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. @@ -25,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, @@ -50,23 +113,89 @@ 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( +pub(crate) fn compute_estimate( + dest: Option<&PaymentInstructions>, 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 + cfg: &MaxSendableConfig, + find_route: F, +) -> Result +where + F: FnOnce(RouteParameters) -> Result, +{ + let balance_msat = sum_outbound_balance(channels, lsp_pubkey)?; + 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. +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, + } +} + +/// 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, +/// 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| { @@ -76,29 +205,62 @@ pub(crate) fn compute_estimate( .saturating_add(c.next_outbound_htlc_limit_msat), ) }) - .ok_or(MaxSendableError::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 - // 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); +/// 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, +} - Ok(MaxSendableEstimate { - amount_msat: balance_msat.saturating_sub(buffer_msat), - fee_budget_msat: buffer_msat, - }) +/// 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; - const BPS: u16 = 100; - const FLOOR: u64 = 10; - fn lsp() -> PublicKey { PublicKey::from_str("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") .unwrap() @@ -117,10 +279,47 @@ 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 = buffer_estimate(&[], &lsp(), &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } @@ -128,40 +327,34 @@ 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 = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } #[test] fn no_usable_channel_when_lsp_channel_unusable() { - // Channel exists with the LSP but is mid-open or mid-splice - // — explicitly distinct from "balance is zero". + // 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 = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } #[test] fn dust_balance_below_floor_returns_zero() { - // 5 sats of outbound. Floor buffer is 10 sats → buffer wins, - // amount saturates to zero. The estimate is "you have - // liquidity, but it can't cover even the floor fee" — not an - // error. + // 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 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 } #[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(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.amount_msat, 0); assert_eq!(est.fee_budget_msat, 10_000); } @@ -171,7 +364,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, 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 } @@ -181,49 +374,95 @@ 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 } #[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 snap(lsp, true, 30_000_000), // 30k 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, 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. + // 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(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).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(); + 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 = 20k 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); + } }