Skip to content

v0 max_withdrawable estimator#20

Open
amackillop wants to merge 7 commits into
masterfrom
austin_mdk-863_vo-max-withrawable
Open

v0 max_withdrawable estimator#20
amackillop wants to merge 7 commits into
masterfrom
austin_mdk-863_vo-max-withrawable

Conversation

@amackillop
Copy link
Copy Markdown
Contributor

v0 max_withdrawable estimator

Adds a "max sendable over Lightning right now" number to /getbalance
so consumers can drive a one-click drain to a destination.
Raw balanceSat overstates that by the routing-fee buffer.

Wire change

GET /getbalance gains maxWithdrawableSat: Option<u64>:

  • null — no usable LSP channel.
  • Some(0) — channel exists but the balance is fully consumed by the
    fee 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. NoUsableChannel is the only
error variant; dust balances return Ok(amount_msat: 0), not an
error.

Commits

Six commits:

  1. Pure types + compute_estimate + unit tests
  2. MaxWithdrawableConfig on NodeConfig + TOML deserialization
  3. MdkClient::max_withdrawable accessor (inline compute)
  4. Wire field into GetBalanceResponse + handler switch to Arc<MdkClient>
  5. End-to-end integration test against the regtest harness
  6. Surface max-sendable in demo wallet

v0 algorithm

balance_msat = Σ next_outbound_htlc_limit_msat over usable LSP channels
buffer_msat  = max(balance_msat × bps / 10_000, floor_sats × 1000)
amount_msat  = balance_msat.saturating_sub(buffer_msat)

Destination-agnostic. v1 swaps the body
for Router::find_route-based fee inversion. The accessor stays.

UX invariant

Never a positive balanceSat paired with null maxWithdrawableSat.
Both fields project from the same list_channels() snapshot inside a
single 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
image

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

1 participant