diff --git a/Cargo.lock b/Cargo.lock index 6e33758..f067eb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,6 +348,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bitcoin-payment-instructions" +version = "0.6.0" +source = "git+https://github.com/moneydevkit/bitcoin-payment-instructions?rev=bdcef061488bcc619142010a2a69cd49462d8843#bdcef061488bcc619142010a2a69cd49462d8843" +dependencies = [ + "bitcoin", + "dnssec-prover", + "getrandom 0.3.4", + "lightning 0.3.0+git", + "lightning-invoice 0.34.0+git", +] + [[package]] name = "bitcoin-units" version = "0.1.2" @@ -1395,7 +1407,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "ldk-node" version = "0.7.0" -source = "git+https://github.com/moneydevkit/ldk-node?rev=5dce44b6e795560bbf62f49d3648308ce88a0586#5dce44b6e795560bbf62f49d3648308ce88a0586" +source = "git+https://github.com/moneydevkit/ldk-node?rev=e5fcce065fa97c522de0f8fec87beb9e7e541456#e5fcce065fa97c522de0f8fec87beb9e7e541456" dependencies = [ "base64 0.22.1", "bdk_chain", @@ -1405,22 +1417,22 @@ dependencies = [ "bip21", "bip39", "bitcoin", - "bitcoin-payment-instructions", + "bitcoin-payment-instructions 0.6.0 (git+https://github.com/moneydevkit/bitcoin-payment-instructions?rev=6796e87525d6c564e1332354a808730e2ba2ebf8)", "chrono", "electrum-client", "esplora-client", "libc", - "lightning 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-background-processor 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-block-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-invoice 0.34.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-liquidity 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-macros 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-net-tokio 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-persister 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-rapid-gossip-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-transaction-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", - "lightning-types 0.3.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-background-processor 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-block-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-invoice 0.34.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-liquidity 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-macros 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-net-tokio 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-persister 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-rapid-gossip-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-transaction-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning-types 0.3.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", "log", "prost", "rand 0.9.2", @@ -1437,7 +1449,7 @@ dependencies = [ [[package]] name = "ldk-node" version = "0.7.0" -source = "git+https://github.com/moneydevkit/ldk-node?rev=e5fcce065fa97c522de0f8fec87beb9e7e541456#e5fcce065fa97c522de0f8fec87beb9e7e541456" +source = "git+https://github.com/moneydevkit/ldk-node?rev=f13fcead7e02ef4b77489a83854f204de11e902b#f13fcead7e02ef4b77489a83854f204de11e902b" dependencies = [ "base64 0.22.1", "bdk_chain", @@ -1447,22 +1459,22 @@ dependencies = [ "bip21", "bip39", "bitcoin", - "bitcoin-payment-instructions", + "bitcoin-payment-instructions 0.6.0 (git+https://github.com/moneydevkit/bitcoin-payment-instructions?rev=bdcef061488bcc619142010a2a69cd49462d8843)", "chrono", "electrum-client", "esplora-client", "libc", - "lightning 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-background-processor 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-block-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-invoice 0.34.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-liquidity 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-macros 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-net-tokio 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-persister 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-rapid-gossip-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-transaction-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", - "lightning-types 0.3.0 (git+https://github.com/moneydevkit/rust-lightning?rev=eecf53e7c763e8ae8e436585858db506a7aac588)", + "lightning 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-background-processor 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-block-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-invoice 0.34.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-liquidity 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-macros 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-net-tokio 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-persister 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-rapid-gossip-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-transaction-sync 0.2.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", + "lightning-types 0.3.0 (git+https://github.com/moneydevkit/rust-lightning?rev=f56f47fe6c874771cd079faaacf96c42b32fbe72)", "log", "prost", "rand 0.9.2", @@ -1867,7 +1879,7 @@ dependencies = [ "axum", "base64 0.22.1", "bech32", - "bitcoin-payment-instructions", + "bitcoin-payment-instructions 0.6.0 (git+https://github.com/moneydevkit/bitcoin-payment-instructions?rev=6796e87525d6c564e1332354a808730e2ba2ebf8)", "chrono", "clap", "corepc-node", @@ -1875,8 +1887,8 @@ dependencies = [ "hex-conservative", "hmac", "http-body-util", - "ldk-node 0.7.0 (git+https://github.com/moneydevkit/ldk-node?rev=5dce44b6e795560bbf62f49d3648308ce88a0586)", "ldk-node 0.7.0 (git+https://github.com/moneydevkit/ldk-node?rev=e5fcce065fa97c522de0f8fec87beb9e7e541456)", + "ldk-node 0.7.0 (git+https://github.com/moneydevkit/ldk-node?rev=f13fcead7e02ef4b77489a83854f204de11e902b)", "log", "reqwest 0.12.28", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 27ac235..103b1fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ demo = [] [dependencies] # Branch: https://github.com/moneydevkit/ldk-node/tree/lsp-0.7.0_accept-underpaying-htlcs_with_timing_logs -ldk-node = { git = "https://github.com/moneydevkit/ldk-node", rev = "5dce44b6e795560bbf62f49d3648308ce88a0586" } +ldk-node = { git = "https://github.com/moneydevkit/ldk-node", rev = "f13fcead7e02ef4b77489a83854f204de11e902b" } # Pinned to the same git rev as ldk-node's transitive pull to avoid duplicate # crate compilation. Verify with `cargo tree -d | grep bitcoin-payment-instructions`. diff --git a/src/daemon/api/balance.rs b/src/daemon/api/balance.rs index 8ded8e8..154379f 100644 --- a/src/daemon/api/balance.rs +++ b/src/daemon/api/balance.rs @@ -35,7 +35,7 @@ pub async fn handle_get_balance( .map(|ch| ch.outbound_capacity_msat / 1000) .sum(); - let max_withdrawable_sat = client.max_sendable().ok().map(|e| e.amount_msat / 1000); + let max_withdrawable_sat = client.max_sendable(None).ok().map(|e| e.amount_msat / 1000); Ok(Json(GetBalanceResponse { balance_sat: lightning_sat, diff --git a/src/daemon/config.rs b/src/daemon/config.rs index 0b5da5d..cc41657 100644 --- a/src/daemon/config.rs +++ b/src/daemon/config.rs @@ -96,6 +96,7 @@ struct SpliceSection { struct MaxSendableSection { fee_buffer_bps: Option, fee_buffer_floor_sats: Option, + route_retry_fee_multiplier_bps: Option, } pub struct MdkConfig { @@ -190,6 +191,9 @@ pub fn load_config(path: &str) -> io::Result { fee_buffer_floor_sats: s .fee_buffer_floor_sats .unwrap_or(defaults.fee_buffer_floor_sats), + route_retry_fee_multiplier_bps: s + .route_retry_fee_multiplier_bps + .unwrap_or(defaults.route_retry_fee_multiplier_bps), } } None => MaxSendableConfig::default(), diff --git a/src/mdk/client.rs b/src/mdk/client.rs index 23d95f0..72206e9 100644 --- a/src/mdk/client.rs +++ b/src/mdk/client.rs @@ -1,6 +1,7 @@ use std::str::FromStr; use std::sync::Arc; +use bitcoin_payment_instructions::PaymentInstructions; use chrono::{DateTime, SecondsFormat}; use ldk_node::bitcoin::hashes::sha256; use ldk_node::bitcoin::hashes::Hash as _; @@ -16,7 +17,7 @@ use tokio_util::sync::CancellationToken; use crate::mdk::error::{MdkError, SpliceError}; use crate::mdk::max_sendable::{ - compute_estimate, ChannelSnapshot, MaxSendableConfig, MaxSendableError, MaxSendableEstimate, + self, ChannelSnapshot, MaxSendableConfig, MaxSendableError, MaxSendableEstimate, }; use crate::mdk::mdk_api::client::MdkApiClient; use crate::mdk::mdk_api::types::{ @@ -145,16 +146,29 @@ impl MdkClient { /// Best-effort estimate of the largest amount that can flow out /// over Lightning right now, with routing-fee headroom subtracted. - /// Computed inline from `node.list_channels()` on every call so - /// the result reflects in-flight HTLCs and reserve as of *now*. - pub fn max_sendable(&self) -> Result { + /// Recomputed from `node.list_channels()` on every call so the + /// result reflects in-flight HTLCs and reserve as of *now*. + /// + /// `dest = None` returns a buffer-based estimate; `Some(_)` + /// drives `Node::find_route` and subtracts the real fees. See + /// [`crate::mdk::max_sendable`] for the full dispatch table. + pub fn max_sendable( + &self, + dest: Option<&PaymentInstructions>, + ) -> Result { let snaps: Vec = self .node .list_channels() .iter() .map(ChannelSnapshot::from) .collect(); - compute_estimate(&snaps, &self.lsp_pubkey, &self.max_sendable_cfg) + max_sendable::compute_estimate( + dest, + &snaps, + &self.lsp_pubkey, + &self.max_sendable_cfg, + |rp| self.node.find_route(rp).map_err(|e| format!("{e}")), + ) } /// Splice `amount_sats` of confirmed on-chain funds into the diff --git a/src/mdk/max_sendable.rs b/src/mdk/max_sendable.rs index 0d2ef87..6e89b69 100644 --- a/src/mdk/max_sendable.rs +++ b/src/mdk/max_sendable.rs @@ -1,28 +1,60 @@ //! 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 std::time::Instant; +use bitcoin_payment_instructions::{ + PaymentInstructions, PaymentMethod, PossiblyResolvedPaymentMethod, +}; use ldk_node::bitcoin::secp256k1::PublicKey; -use ldk_node::ChannelDetails; +use ldk_node::lightning_invoice::Bolt11Invoice as LdkBolt11Invoice; +use ldk_node::{ChannelDetails, PaymentParameters, Route, RouteParameters}; -/// User-tunable buffer applied to the raw outbound liquidity to -/// reserve headroom for routing fees. +/// 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 struct MaxSendableConfig { - /// Percentage buffer in basis points (1 bps = 0.01 %). Default: 100 (1 %). + /// 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 buffer, in sats. Default: 10. - /// Whichever of the percentage and the floor is larger wins — - /// keeps small-balance estimates honest about base fees. + /// 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 { @@ -30,6 +62,7 @@ impl Default for MaxSendableConfig { Self { fee_buffer_bps: 100, fee_buffer_floor_sats: 10, + route_retry_fee_multiplier_bps: 11_000, } } } @@ -41,26 +74,33 @@ pub struct MaxSendableEstimate { /// Amount to surface to the payer as "max sendable", in msat. /// Zero when the balance is fully consumed by the buffer (dust). pub amount_msat: u64, - /// The buffer subtracted from `balance_at_compute_msat` to reach - /// `amount_msat`. Doubles as a hint for `max_total_routing_fee_msat`. + /// Subtracted from `balance_at_compute_msat` to reach `amount_msat`. + /// Doubles as a hint for `max_total_routing_fee_msat`. pub fee_budget_msat: u64, /// Raw outbound liquidity at compute time (sum of usable LSP /// channels' `next_outbound_htlc_limit_msat`). pub balance_at_compute_msat: u64, - /// Wall-clock-free monotonic timestamp of when the estimate was computed. pub computed_at: Instant, } #[derive(Debug, PartialEq, Eq)] pub enum MaxSendableError { - /// No usable LSP channel exists yet — the node is still booting, - /// the channel is opening, or it was force-closed. Distinct from - /// "balance is dust" (which returns `Ok(amount_msat: 0)`). + /// 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, @@ -78,29 +118,93 @@ impl From<&ChannelDetails> for ChannelSnapshot { } } -/// Pure compute: given a snapshot of channels, the LSP pubkey, and a -/// buffer config, return the estimate. -/// -/// ```text -/// balance_msat = Σ next_outbound_htlc_limit_msat over usable LSP channels -/// buffer_msat = max(balance_msat × buffer_bps / 10_000, buffer_floor_sats × 1000) -/// amount_msat = balance_msat.saturating_sub(buffer_msat) -/// fee_budget_msat = buffer_msat -/// ``` -/// -/// `Err(NoUsableChannel)` is returned only when no channel matches -/// `counterparty == lsp_pubkey && is_usable`. A dust-level balance -/// where the buffer eats everything yields `Ok(amount_msat: 0)` — the -/// UI distinguishes "0 sats sendable" from "no channel yet". -pub(crate) fn compute_estimate( +/// 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, cfg: &MaxSendableConfig, -) -> Result { - // `Option` accumulator distinguishes "no channel matched" - // (None → NoUsableChannel) from "channel(s) matched, sum is 0" - // (Some(0) → Ok with dust semantics). - let balance_msat = channels + 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, + ); + // TODO: Post channel consolidation, consider setting this to one to not use MPP + // route_params.payment_params.max_path_count = 1; + 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. + 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, + balance_at_compute_msat: balance_msat, + computed_at: Instant::now(), + } +} + +/// 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, + balance_at_compute_msat: balance_msat, + computed_at: Instant::now(), + } +} + +/// 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| { @@ -109,26 +213,59 @@ 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) * (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); +/// How [`compute_estimate`] should price the chosen Lightning +/// destination — a decision, not a route. +#[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, - balance_at_compute_msat: balance_msat, - computed_at: Instant::now(), - }) +/// 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. +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 { @@ -149,10 +286,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, &MaxSendableConfig::default()); + let res = buffer_estimate(&[], &lsp(), &MaxSendableConfig::default()); assert!(matches!(res, Err(MaxSendableError::NoUsableChannel))); } @@ -160,41 +334,35 @@ 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, &MaxSendableConfig::default()); + 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, &MaxSendableConfig::default()); + 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, &MaxSendableConfig::default()).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 + assert_eq!(est.fee_budget_msat, 10_000); assert_eq!(est.balance_at_compute_msat, 5_000); } #[test] fn balance_exactly_equals_buffer_returns_zero() { - // 10 sats balance, 10 sat floor → amount = 0 exactly, - // fee_budget = 10_000 msat. let lsp = lsp(); let chans = [snap(lsp, true, 10_000)]; - let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.amount_msat, 0); assert_eq!(est.fee_budget_msat, 10_000); } @@ -203,10 +371,10 @@ mod tests { fn normal_case_percentage_buffer_dominates() { // 100k sats × 1% = 1000 sats > 10-sat floor → percentage wins. let lsp = lsp(); - let chans = [snap(lsp, true, 100_000_000)]; // 100k sats - let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); - assert_eq!(est.fee_budget_msat, 1_000_000); // 1000 sats - assert_eq!(est.amount_msat, 99_000_000); // 99k sats + 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); assert_eq!(est.balance_at_compute_msat, 100_000_000); } @@ -214,56 +382,96 @@ mod tests { fn normal_case_floor_buffer_dominates() { // 500 sats × 1% = 5 sats < 10-sat floor → floor wins. let lsp = lsp(); - let chans = [snap(lsp, true, 500_000)]; // 500 sats - let est = compute_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); - assert_eq!(est.fee_budget_msat, 10_000); // 10-sat floor - assert_eq!(est.amount_msat, 490_000); // 490 sats + 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] 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, &MaxSendableConfig::default()).unwrap(); + 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.balance_at_compute_msat, 80_000_000); - assert_eq!(est.fee_budget_msat, 800_000); // 1% of 80k sats + 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, &MaxSendableConfig::default()).unwrap(); + let est = buffer_estimate(&chans, &lsp, &MaxSendableConfig::default()).unwrap(); assert_eq!(est.balance_at_compute_msat, 10_000_000); - assert_eq!(est.fee_budget_msat, 100_000); // 1% of 10k sats + assert_eq!(est.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 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 = 20k sats + let est = buffer_estimate(&chans, &lsp, &cfg).unwrap(); + assert_eq!(est.fee_budget_msat, 20_000_000); 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_eq!(est.balance_at_compute_msat, 100); + 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); + } }