Skip to content

Destination-aware max-sendable#41

Merged
amackillop merged 5 commits into
mainfrom
austin_mdk-865_max-sendable-w-destination
May 22, 2026
Merged

Destination-aware max-sendable#41
amackillop merged 5 commits into
mainfrom
austin_mdk-865_max-sendable-w-destination

Conversation

@amackillop

Copy link
Copy Markdown
Contributor

Ports mdkd#22 into
lightning-js. MdkNode#getMaxSendable now accepts an optional
destination and, when supplied, computes the fee budget from a real
Node::find_route against that payee instead of the static 1% buffer.

JS surface

node.getMaxSendable()                  // unchanged: buffer-based estimate
node.getMaxSendable("lnbc1...")        // route-based estimate
  • null still means "no usable LSP channel" (transient case).
  • Fixed-amount destinations, on-chain-only destinations, parse failures,
    and routing failures throw napi errors instead of returning null.
  • New optional routeRetryFeeMultiplierBps on MaxSendableConfig
    (default 11_000 = 1.1x) scales the reported fee budget so retries
    along a costlier path still fit.

Scope

  • BOLT11 zero-amount: route-based.
  • BOLT12 / LNURL-pay / HRN: parse, then fall back to the buffer until
    invoice/HRN resolution moves into the estimator.
  • Fixed-amount BOLT11/BOLT12: InvalidArg (the payee dictated the
    amount, nothing to estimate).

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.
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.
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.
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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc19fbe3a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/max_sendable.rs
Comment on lines +156 to +157
RouteParameters::from_payment_params_and_value(payment_params, balance_msat);
let route = find_route(route_params).map_err(MaxSendableError::RoutingFailure)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route against spendable amount, not full outbound balance

compute_estimate asks find_route for balance_msat as the payment value and only subtracts routing fees afterward, so the route search is performed for an amount that already consumes the entire outbound liquidity. When any non-zero routing fee is required (the common case), there are channels where paying balance_msat is impossible but paying balance_msat - fees is valid; this path currently returns RoutingFailure instead of a max-sendable estimate for those destinations.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An astute observation but I am okay with a single iteration here

@amackillop amackillop requested a review from martinsaposnic May 21, 2026 14:21
Comment thread src/max_sendable.rs Outdated
@@ -58,15 +121,87 @@ 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this needs to be deleted as now compute_estimate() exists?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this got messed up doesn't even compile. Need to get the pre-commit checks working on my machine for this repo

Comment thread src/max_sendable.rs Outdated
Comment on lines +342 to +345
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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

res overwritten multiple times?

Comment thread src/lib.rs Outdated
/// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is not true anymore? you need a running node?

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.
@amackillop amackillop merged commit 78a326f into main May 22, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants