Auto-splice onchain funds into LSP channel#9
Merged
Conversation
b38c205 to
36be98d
Compare
36be98d to
c58f938
Compare
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.
c58f938 to
eecc932
Compare
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.
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 BDKselection at the live
ChannelFundingfeerate and returns theexact wallet contribution (moneydevkit/ldk-node#28). Backports
splice_in_with_all's amountmath (rust-lightning
4a2cefc1) without the splice API reworkthat sits between it and our pin (which is painful to bump because
of LSPS4 fork rebases).
Commits
ec07d27— PlumbSpliceInitiated/SplicePending/SpliceFailedthrough
MdkEvent.49507a3—SpliceConfig { enabled, poll_interval }onNodeConfig,default 30s, optional
[splice]TOML section.73c7bd8—MdkClient::splice_inwrapper with typedSpliceErrorADT (
InsufficientFunds/Rejected/ChannelNotUsable).179a0d7—splice_managerbackground task. Bumps theldk-node pin to pick up
Node::get_max_splice_in_amount, thenon each tick polls
list_balances, picks the highest-capacityusable LSP channel, queries the accessor for the exact wallet
contribution, and calls
splice_in. In-flight tracking viafunding_txoflip + 60s BDK-resync grace window preventsre-firing during the splice handshake (the channel can briefly
look
is_usable=trueafter our 0-confsplice_lockedbutbefore the LSP returns theirs, gated on
inbound_splice_minimum_depthconfs).36be98d— End-to-end integration test: close → on-chain →reopen → splice → assert capacity absorbed the consumed delta.
Key design notes
ChannelReadycase where akick would help also has either an HTLC in flight or the
on-chain balance just consumed — both skip-paths in
check().is_usablealone is unsound for in-flight detection — seethe
funding_txostate machine insplice_manager.rs. Unittests cover the four state transitions.
Node::get_max_splice_in_amountbuilds 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
d9336f23this accessor is the only thing that getsreplaced with
splice_in_with_all.wrong: client could RBF the splice input after spending
post-splice capacity over Lightning.
Test plan
just check(rustfmt + clippy + tests)test_auto_splice_after_channel_close_and_reopenend-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.