Skip to content

Auto-splice onchain funds into LSP channel#9

Merged
amackillop merged 5 commits into
masterfrom
austin_mdk-715_client-splice-in
May 12, 2026
Merged

Auto-splice onchain funds into LSP channel#9
amackillop merged 5 commits into
masterfrom
austin_mdk-715_client-splice-in

Conversation

@amackillop
Copy link
Copy Markdown
Contributor

@amackillop amackillop commented Apr 29, 2026

Summary

Implements client-side auto-splicing (MDK-715):
when confirmed on-chain funds appear in the client wallet (typically
after an LSP channel closes), a background task automatically splices
them back into the existing LSP channel. Symmetric counterpart of the
LSP-side splice-instead-of-open already in production.

Fee handling lives in our ldk-node fork: a small surgical patch
adds Node::get_max_splice_in_amount, which dry-runs BDK
selection at the live ChannelFunding feerate and returns the
exact wallet contribution (moneydevkit/ldk-node#28). Backports splice_in_with_all's amount
math (rust-lightning 4a2cefc1) without the splice API rework
that sits between it and our pin (which is painful to bump because
of LSPS4 fork rebases).

Commits

  1. ec07d27 — Plumb SpliceInitiated / SplicePending / SpliceFailed
    through MdkEvent.
  2. 49507a3SpliceConfig { enabled, poll_interval } on NodeConfig,
    default 30s, optional [splice] TOML section.
  3. 73c7bd8MdkClient::splice_in wrapper with typed SpliceError
    ADT (InsufficientFunds / Rejected / ChannelNotUsable).
  4. 179a0d7splice_manager background task. Bumps the
    ldk-node pin to pick up Node::get_max_splice_in_amount, then
    on each tick polls list_balances, picks the highest-capacity
    usable LSP channel, queries the accessor for the exact wallet
    contribution, and calls splice_in. In-flight tracking via
    funding_txo flip + 60s BDK-resync grace window prevents
    re-firing during the splice handshake (the channel can briefly
    look is_usable=true after our 0-conf splice_locked but
    before the LSP returns theirs, gated on
    inbound_splice_minimum_depth confs).
  5. 36be98d — End-to-end integration test: close → on-chain →
    reopen → splice → assert capacity absorbed the consumed delta.

Key design notes

  • Pure timer, no event kick. Every ChannelReady case where a
    kick would help also has either an HTLC in flight or the
    on-chain balance just consumed — both skip-paths in check().
  • is_usable alone is unsound for in-flight detection — see
    the funding_txo state machine in splice_manager.rs. Unit
    tests cover the four state transitions.
  • Exact fee via BDK dry-run. Node::get_max_splice_in_amount
    builds the same drain transaction the splice will eventually
    produce and returns wallet-contribution = drain output value
    minus foreign input total. Replaces the earlier static 400 vB
    estimate (capped at N≤3 client UTXOs) so consolidation flows
    with many confirmed UTXOs work in one tick. Once we bump past
    ldk-node d9336f23 this accessor is the only thing that gets
    replaced with splice_in_with_all.
  • No 0-conf for client-initiated splice. Trust direction is
    wrong: client could RBF the splice input after spending
    post-splice capacity over Lightning.

Test plan

  • just check (rustfmt + clippy + tests)
  • New integration test test_auto_splice_after_channel_close_and_reopen
  • Splice-manager unit tests cover advance/skip/in-flight transitions
  • Verified against staging (Mutinynet, 3-conf LSP policy): clean
    end-to-end trace from accessor returning the exact amount through
    promotion, in-flight skip path triggered correctly during the
    LSP's confirmation wait, channel value bumped by exactly the
    spliced contribution.

@amackillop amackillop force-pushed the austin_mdk-715_client-splice-in branch from b38c205 to 36be98d Compare May 6, 2026 21:26
@amackillop amackillop changed the title Austin mdk 715 client splice in Auto-splice onchain funds into LSP channel May 6, 2026
@amackillop amackillop requested a review from martinsaposnic May 7, 2026 12:58
@amackillop amackillop marked this pull request as ready for review May 7, 2026 12:58
@amackillop amackillop force-pushed the austin_mdk-715_client-splice-in branch from 36be98d to c58f938 Compare May 8, 2026 14:31
Add SpliceInitiated, SplicePending, and SpliceFailed variants and
translate the two ldk-node splice events in handle_ldk_event.

First commit implementing auto-splice of confirmed onchain
funds back into the LSP channel. The splice manager lands in a
later commit; this one just adds the variants so subsequent commits
do not have to touch the type module again.

SpliceInitiated has no LDK counterpart. The splice manager will
emit it once Node::splice_in returns Ok, before any LDK event
fires. The three variants live together so the lifecycle is easy
to find.

SpliceFailed carries a String reason so the same variant works for
both failure paths: the post-negotiation LDK Event::SpliceFailed
(where we have an abandoned_funding_txo to format) and the
synchronous splice_in error the splice manager will hit (where the
reason is an MdkError). An ADT reason was considered but rejected
as overkill for a value that only feeds logs and webhooks.

The daemon event loop matches PaymentReceived via let-else, so
additive enum changes do not ripple. No webhook fan-out yet;
splice events stay internal until the platform consumes them.
Introduce SpliceConfig with two knobs (enabled, poll_interval) and
thread it through NodeConfig and the daemon TOML loader. No
behaviour change yet; the splice manager that consumes this config
lands in a later commit.

Defaults are enabled=true and poll_interval=30s. 30s balances wasted
ticks against post-reopen responsiveness; tests will override to 1s.
There is deliberately no fee or threshold knob: the live ldk-node
estimate covers fees and ldk-node itself enforces the dust gate, so
extra configuration would just be second-guessing the wallet.

The TOML schema uses poll_interval_secs (u64) rather than a
humantime string to avoid pulling in another dependency for one
field. The [splice] section is optional; missing or partial sections
fall back to SpliceConfig::default(), so existing configs keep
loading.

SpliceConfig lives in the lib (mdk::node) rather than the daemon
crate because the splice manager itself will live in the lib too.
The daemon imports it from there.
Wrap Node::splice_in with local validation (channel exists, channel
is usable, amount > 0) and a typed SpliceError ADT. Cache the
parsed LSP PublicKey on MdkClient so the wrapper and the upcoming
splice manager do not repeat the parse on every call.

The error mapping is the meaningful part. ldk-node's NodeError
exposes a single ChannelSplicingFailed variant for nearly every
splice failure (peer rejected, channel not yet ready, coin
selection failed under fee pressure) and InsufficientFunds for the
broad spendable-balance check. The splice manager needs to react
differently to "no funds, retry next tick" vs "something is
genuinely wrong", so the wrapper exposes the two cases as
SpliceError::InsufficientFunds and SpliceError::Rejected.

The mapping lives in a free helper (map_splice_error) rather than
a From impl. NodeError::InsufficientFunds is also produced by
open_channel and on-chain wallet paths, so a blanket From<NodeError>
for SpliceError would be wrong outside splice context. A free
function keeps the conversion's narrow scope obvious by name and
location, and avoids adding a discoverable trait impl that future
code might mistakenly use elsewhere.

ldk-node does not expose a way to distinguish the fee-too-high
coin-selection path from other ChannelSplicingFailed cases (the
detail only appears in a log line). The splice manager will treat
any Rejected as a retry candidate anyway, so we accept the lossy
mapping rather than parsing log output. Anything other than
InsufficientFunds collapses into Rejected.

ChannelNotUsable is a third, locally-detected variant. It could
have been folded into Rejected and left for ldk-node to surface,
but catching it before the network round-trip is cheaper and gives
the splice manager a clear signal that the channel was filtered
out by our own pre-check, not by the peer.

The daemon HTTP error mapper folds all SpliceError cases into
Internal for now. A future POST /splicein endpoint may want finer
status codes; the wrapper is only consumed internally by the
splice manager today, so that refinement is deferred.
Spawns a polling loop from MdkClient::start that watches for
confirmed on-chain funds and automatically splices them into an
existing LSP channel, the symmetric counterpart of the LSP-side
splice-instead-of-open on the inbound path. Default poll is 30s;
tunable via SpliceConfig.

Each tick:
  1. Read list_balances().spendable_onchain_balance_sats. Skip if 0.
  2. Pick the highest-capacity channel where counterparty is the
     configured LSP, is_usable, and has a funding_txo.
  3. Call Node::get_max_splice_in_amount, which dry-runs BDK
     selection at the live ChannelFunding feerate and returns the
     exact wallet contribution. Skip on Err (typically the
     post-fee result was dust).
  4. splice_in(amount). Emit SpliceInitiated on Ok; log+continue
     on InsufficientFunds; emit SpliceFailed on other.

The accessor backports splice_in_with_all's amount math from
ldk-node 4a2cefc1 without requiring the full splice API rework
that sits between it and our pin (which is painful to bump because
of LSPS4 fork rebases on rust-lightning). When we do bump past
d9336f23, the accessor is the only thing that gets replaced with
splice_in_with_all and this caller follows.

In-flight tracking. is_usable alone is not a sound signal: a
channel mid-splice can still report is_usable=true after we've
sent our 0-conf splice_locked but before the LSP returns theirs
(gated on inbound_splice_minimum_depth confirmations). The manager
keeps a HashMap<UCID, InFlight { initial_funding_txo, promoted_at }>
driven by ChannelDetails::funding_txo. While the channel's active
funding still equals the outpoint captured at splice_in time, the
splice is in flight and the channel is excluded from the candidate
pool (rust-lightning only swaps self.funding once both sides have
exchanged splice_locked, in maybe_promote_splice_funding). Once
funding_txo flips, the entry is held for BDK_RESYNC_GRACE (60s) so
the next tick doesn't race continuously_sync_wallets and re-fire
on a UTXO BDK hasn't yet observed as spent.
SkipReason::SpliceAlreadyInFlight surfaces it in logs.

Idempotency across mdkd restart still relies on BDK locking the
selected UTXOs in the pending splice tx. The in-flight map is
in-memory only.
Drives the full close → on-chain → reopen → auto-splice loop end to
end and asserts the on-chain balance lands inside the LSP channel.

  1. Pay an invoice — LSP opens JIT channel #1.
  2. /closechannel + mine until the close output is spendable on the
     client side. Snapshot onchain_after_close.
  3. Pay another invoice — LSP opens a fresh JIT channel #2.
  4. Mine blocks while polling /getbalance until onchainBalanceSat
     drops below 10% of onchain_after_close. Client splices are not
     0-conf, so the splice tx must confirm and the LSP must return
     splice_locked before we observe the drop.
  5. Snapshot the post-splice channel capacity and assert it covers
     the consumed on-chain delta.

The test asserts conservation across two observations rather than
racing a pre-splice capacity snapshot: on-chain drops AND channel
capacity grew by at least that delta. Together they pin the funds
to the channel without depending on snapshot timing.

Test config sets [splice] poll_interval_secs = 1 (default 30s would
balloon test runtime). The fast tick is harmless for the existing
tests: none of them pair an on-chain balance with a usable LSP
channel inside the same test, so the splice manager hits the
NoUsableLspChannel skip every tick.
@amackillop amackillop force-pushed the austin_mdk-715_client-splice-in branch from c58f938 to eecc932 Compare May 11, 2026 15:02
@amackillop amackillop merged commit 85f7372 into master May 12, 2026
2 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.

1 participant