v0 max_withdrawable estimator#20
Open
amackillop wants to merge 7 commits into
Open
Conversation
First slice of the v0 "max sendable" estimator for better payout UX. mdk consolidates outbound liquidity on its LSP channel, so the payer needs `balance - routing_fee(amount)`, not raw balance. A precise estimate would call `Router::find_route` and invert per-hop fees, but ldk-node keeps the router private. v0 ships a destination-agnostic best-effort number: subtract a percentage buffer (default 1%, 10-sat floor) from the sum of `next_outbound_htlc_limit_msat` across usable LSP channels. v1 swaps the body of `compute_estimate` once the ldk-node fork lands.
Plumb the buffer config through both the library-side `NodeConfig` and the daemon-side `MdkConfig`, so the upcoming background refresher and client accessor can reach it. TOML section is `[max_withdrawable]` with snake_case field names mirroring the Rust struct. The whole section is optional; an absent section yields `MaxWithdrawableConfig::default()` (100 bps, 10-sat floor). This matches the splice section's pattern and means existing deployments pick up sensible defaults without touching config.toml.
Inline-compute accessor over `node.list_channels()`: projects each `ChannelDetails` into `ChannelSnapshot`, calls the pure `compute_estimate`, returns. No cache, no background task, no lifecycle wiring.
Surfaces the new accessor over HTTP. The payouts UX needs a
"max sendable" number to drive a one-click drain a destination, and the
raw `balance_sat` overstates that by the routing fee.
The handler now takes `Arc<MdkClient>` instead of `Arc<Node>` so it
can reach both the node (for the existing balance math) and the new
accessor. `AppState.mdk_client` was already in scope; the route
wiring change is a one-liner.
`Option<u64>` semantics preserved end-to-end: `null` means no usable
LSP channel, `Some(0)` means the channel exists but liquidity is
fully consumed by the fee buffer. The UX invariant ("never positive
`balanceSat` alongside `null` `maxWithdrawableSat`") holds because
both fields project from the same `list_channels()` snapshot inside
a single request.
JSON wire name is `maxWithdrawableSat` via the existing camelCase
serde rename on the struct.
Locks in the wire contract that consumers read: `maxWithdrawableSat` is null with no channel, turns into a positive number once a usable LSP channel exists, and sits below `balanceSat` once the fee buffer kicks in. The value cross-check uses a 10% tolerance rather than a tight bound against the configured 1% buffer. `balanceSat` projects from `outbound_capacity_msat` while `maxWithdrawableSat` projects from `next_outbound_htlc_limit_msat`, which is further reduced by per-HTLC limits, dust exposure, and in-flight HTLCs. A tight bound would go flaky for reasons unrelated to the buffer math. The pre-channel case is the one that really matters for the UX invariant: never positive `balanceSat` with null `maxWithdrawableSat`. Both fields come from the same `list_channels()` snapshot inside a single request, so they can't disagree.
Surfaces the new `maxWithdrawableSat` field from `/getbalance` as a sub-line under the Lightning Balance card so the demo wallet matches what lightning-js on moneydevkit.com will render. Null renders as an em dash rather than being hidden. The Lightning Balance card itself only ever shows zero (not null), so a visible "Max sendable: —" alongside a zero balance reinforces the UX invariant: there is no usable LSP channel right now.
The accessor is destination-agnostic; it returns how much can be sent over Lightning right now. Withdrawal-to-configured-address is a higher-level concern. v1 will extend this with a destination-aware variant, where the BOLT11 case in particular makes no sense to call a "withdrawal". The JSON field `max_withdrawable_sat` on `/getbalance` is preserved since that specific endpoint is the withdrawal-display contract. That endpoint is in the daemon which is the higher level consumer. This will be similar to how mdk-checkout uses this API but instead using the configured WITHDRAWL_DESTINATION.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v0
max_withdrawableestimatorAdds a "max sendable over Lightning right now" number to
/getbalanceso consumers can drive a one-click drain to a destination.
Raw
balanceSatoverstates that by the routing-fee buffer.Wire change
GET /getbalancegainsmaxWithdrawableSat: Option<u64>:null— no usable LSP channel.Some(0)— channel exists but the balance is fully consumed by thefee buffer (dust).
Some(n)—n = balance_msat − max(balance × bps / 10_000, floor × 1000)in sats.Defaults:
fee_buffer_bps = 100(1%),fee_buffer_floor_sats = 10.Tunable via a new top-level
[max_withdrawable]TOML section.Library surface
MdkClient::max_withdrawable() -> Result<MaxWithdrawableEstimate, MaxWithdrawableError>,re-exported as
mdk::max_withdrawable.NoUsableChannelis the onlyerror variant; dust balances return
Ok(amount_msat: 0), not anerror.
Commits
Six commits:
compute_estimate+ unit testsMaxWithdrawableConfigonNodeConfig+ TOML deserializationMdkClient::max_withdrawableaccessor (inline compute)GetBalanceResponse+ handler switch toArc<MdkClient>v0 algorithm
Destination-agnostic. v1 swaps the body
for
Router::find_route-based fee inversion. The accessor stays.UX invariant
Never a positive
balanceSatpaired withnullmaxWithdrawableSat.Both fields project from the same
list_channels()snapshot inside asingle request, so they can't disagree. The integration test pins
this down explicitly (no-channel case + post-JIT-open case).
Demo wallet example
1% fee buffer reserve
