From 16c5ecd6ff3c88b94681cc9c0379a97f0783433d Mon Sep 17 00:00:00 2001 From: amackillop Date: Mon, 11 May 2026 12:43:06 -0700 Subject: [PATCH 1/4] Add SpliceConfig to MdkNodeOptions Plumb auto-splice manager configuration through the napi surface ahead of the manager itself. New optional `splice` field on MdkNodeOptions with two knobs: `enabled` (default true) and `pollIntervalSecs` (default 30, matching mdkd). Cache the parsed LSP PublicKey on MdkNode rather than re-parsing it per splice tick once the manager lands. --- index.d.ts | 12 ++++++++++++ src/lib.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/index.d.ts b/index.d.ts index 7159235..707869d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -40,6 +40,18 @@ export interface MdkNodeOptions { lspNodeId: string lspAddress: string scoringParamOverrides?: ScoringParamOverrides + splice?: SpliceConfig +} +/** + * Configuration for the auto-splice manager. The manager wakes up every + * `poll_interval_secs`, reads the spendable on-chain balance, and splices it + * into the largest usable LSP channel when one is available. + */ +export interface SpliceConfig { + /** Enable the auto-splice background manager. Default: true. */ + enabled?: boolean + /** Poll interval in seconds. Default: 30. */ + pollIntervalSecs?: number } export interface PaymentMetadata { bolt11: string diff --git a/src/lib.rs b/src/lib.rs index 1f0a022..5894179 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -296,6 +296,44 @@ pub struct MdkNodeOptions { pub lsp_node_id: String, pub lsp_address: String, pub scoring_param_overrides: Option, + pub splice: Option, +} + +/// Configuration for the auto-splice manager. The manager wakes up every +/// `poll_interval_secs`, reads the spendable on-chain balance, and splices it +/// into the largest usable LSP channel when one is available. +#[napi(object)] +pub struct SpliceConfig { + /// Enable the auto-splice background manager. Default: true. + pub enabled: Option, + /// Poll interval in seconds. Default: 30. + pub poll_interval_secs: Option, +} + +/// Resolved splice config with defaults applied. Internal. +#[derive(Debug, Clone, Copy)] +struct ResolvedSpliceConfig { + enabled: bool, + poll_interval: Duration, +} + +impl ResolvedSpliceConfig { + fn from_options(cfg: Option) -> Self { + let default = Self { + enabled: true, + poll_interval: Duration::from_secs(30), + }; + match cfg { + None => default, + Some(c) => Self { + enabled: c.enabled.unwrap_or(default.enabled), + poll_interval: c + .poll_interval_secs + .map(|s| Duration::from_secs(s as u64)) + .unwrap_or(default.poll_interval), + }, + } + } } #[napi(object)] @@ -367,6 +405,14 @@ pub struct NodeChannel { pub struct MdkNode { node: Option, network: Network, + /// LSP counterparty pubkey, parsed and cached from `MdkNodeOptions.lsp_node_id`. + /// Reused by the auto-splice manager to filter eligible channels by counterparty. + #[allow(dead_code)] + lsp_pubkey: PublicKey, + /// Resolved auto-splice manager configuration. Consumed when the manager is + /// spawned from `start_receiving()`. + #[allow(dead_code)] + splice_cfg: ResolvedSpliceConfig, } #[napi] @@ -470,9 +516,13 @@ impl MdkNode { .build_with_vss_store_and_fixed_headers(options.vss_url, vss_identifier, vss_headers) .map_err(|err| napi::Error::from_reason(err.to_string()))?; + let splice_cfg = ResolvedSpliceConfig::from_options(options.splice); + Ok(Self { node: Some(node), network, + lsp_pubkey: lsp_node_id, + splice_cfg, }) } From 13b42ae0e40f555c6e90d38caabb442e16ac8d97 Mon Sep 17 00:00:00 2001 From: amackillop Date: Mon, 11 May 2026 13:30:30 -0700 Subject: [PATCH 2/4] Port auto-splice manager from mdkd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring over the background splice loop without wiring it into the node lifecycle yet. Wiring (start_receiving / stop_receiving) lands in the next commit so the manager change reviews cleanly on its own. * Bump the ldk-node pin to 5dce44b6 (3 commits ahead of the prior pin on the same branch). That rev exposes get_max_splice_in_amount, which the manager needs to dry-run BDK selection at the live channel funding feerate before calling splice_in. * Add tokio-util (default-features = false) for CancellationToken. * New src/splice_manager.rs ported from mdk::splice_manager. The pure decision logic (decide, advance_in_flight) is identical to the source; the effectful shell is adapted to lightning-js: - takes Arc + LSP PublicKey directly (no MdkClient); - silent — drops MdkEvent emission, diagnostics go to stderr via eprintln! with the existing [lightning-js] prefix; - SpliceError + map_splice_error inlined (no shared MdkError). * Port all 6 mdkd unit tests on decide() / advance_in_flight() plus 3 trivial variants. 9 tests, all green. Module is added but not yet spawned — compiles clean (gated under #[allow(dead_code)] until next commit wires it). --- Cargo.toml | 3 +- src/lib.rs | 8 +- src/splice_manager.rs | 542 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 549 insertions(+), 4 deletions(-) create mode 100644 src/splice_manager.rs diff --git a/Cargo.toml b/Cargo.toml index 767f268..6c066dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,13 @@ bitcoin-payment-instructions = { version = "0.5.0", default-features = false, fe "http", ] } # Branch: https://github.com/moneydevkit/ldk-node/commits/lsp-0.7.0_accept-underpaying-htlcs_with_timing_logs -ldk-node = { default-features = false, git = "https://github.com/moneydevkit/ldk-node.git", rev = "5baa1f83a13407818b069b1f990157c8761eb982" } +ldk-node = { default-features = false, git = "https://github.com/moneydevkit/ldk-node.git", rev = "5dce44b6e795560bbf62f49d3648308ce88a0586" } #ldk-node = { path = "../ldk-node" } napi = { version = "2", features = ["napi4"] } napi-derive = "2" tokio = { version = "1", features = ["rt-multi-thread"] } +tokio-util = { version = "0.7", default-features = false } writeable = { version = "=0.6.2", features = ["alloc"] } [build-dependencies] diff --git a/src/lib.rs b/src/lib.rs index 5894179..ee9c163 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,6 +56,8 @@ use tokio::runtime::Runtime; #[macro_use] extern crate napi_derive; +mod splice_manager; + /// Polling interval for event loops and state checks. const POLL_INTERVAL: Duration = Duration::from_millis(10); @@ -312,9 +314,9 @@ pub struct SpliceConfig { /// Resolved splice config with defaults applied. Internal. #[derive(Debug, Clone, Copy)] -struct ResolvedSpliceConfig { - enabled: bool, - poll_interval: Duration, +pub(crate) struct ResolvedSpliceConfig { + pub(crate) enabled: bool, + pub(crate) poll_interval: Duration, } impl ResolvedSpliceConfig { diff --git a/src/splice_manager.rs b/src/splice_manager.rs new file mode 100644 index 0000000..e4e2358 --- /dev/null +++ b/src/splice_manager.rs @@ -0,0 +1,542 @@ +//! Auto-splice manager: a background task that, when the node holds confirmed +//! on-chain funds AND has a usable LSP channel, splices the on-chain balance +//! into the largest such channel. Consolidates liquidity that would otherwise +//! sit idle after a JIT channel closes and a fresh JIT channel opens for the +//! same wallet. +//! +//! Ported from `mdkd::mdk::splice_manager`. The pure decision logic (`decide`, +//! `advance_in_flight`) is identical to the source; the effectful shell is +//! adapted to lightning-js conventions: +//! * no `MdkClient` — takes `Arc` + `PublicKey` (LSP) directly; +//! * no event emission — splice activity is silent, diagnostics go to stderr +//! with the existing `[lightning-js]` prefix. + +#![allow(dead_code)] // Wired in commit 3. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use ldk_node::bitcoin::OutPoint; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::{ChannelDetails, Node, NodeError, UserChannelId}; +use tokio::runtime::Handle; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::ResolvedSpliceConfig; + +/// Once a splice has been promoted (both sides exchanged `splice_locked` and +/// `ChannelDetails::funding_txo` flips to the new outpoint), we keep the +/// channel out of the candidate pool for this long. ldk-node's background +/// `continuously_sync_wallets` task reruns every ~60s; inside that window +/// `list_balances().spendable_onchain_balance_sats` still reports the +/// pre-splice value (BDK has not yet picked up that the UTXO is spent), so a +/// tick that lands here would happily re-fire on the same UTXO. This grace +/// period bridges the gap between promotion and the next BDK sync. +const BDK_RESYNC_GRACE: Duration = Duration::from_secs(60); + +/// Typed splice failure modes. Modeled as an ADT so the apply layer can +/// pattern-match on the failure mode without inspecting strings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SpliceError { + /// The target channel exists but is not currently usable (mid-splice, peer + /// disconnected, mid-monitor-update). Skip this tick and try again. + ChannelNotUsable, + /// BDK selection failed because the post-fee result is below the dust + /// limit. Same retry semantics as `ChannelNotUsable`. + InsufficientFunds, + /// Catch-all for any other `NodeError` returned by `Node::splice_in`. + Rejected, +} + +/// `NodeError::InsufficientFunds` is the one case the splice manager treats +/// specially (silent retry). Everything else collapses to `Rejected`. +fn map_splice_error(e: NodeError) -> SpliceError { + match e { + NodeError::InsufficientFunds => SpliceError::InsufficientFunds, + _ => SpliceError::Rejected, + } +} + +pub(crate) fn spawn( + node: Arc, + lsp_pubkey: PublicKey, + cfg: ResolvedSpliceConfig, + shutdown: CancellationToken, + handle: &Handle, +) -> JoinHandle<()> { + handle.spawn(async move { + run(node, lsp_pubkey, cfg, shutdown).await; + }) +} + +async fn run( + node: Arc, + lsp_pubkey: PublicKey, + cfg: ResolvedSpliceConfig, + shutdown: CancellationToken, +) { + eprintln!( + "[lightning-js] Splice manager started (poll_interval={}s)", + cfg.poll_interval.as_secs() + ); + let mut in_flight: HashMap = HashMap::new(); + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + _ = tokio::time::sleep(cfg.poll_interval) => { + tick(&node, lsp_pubkey, &mut in_flight); + } + } + } + eprintln!("[lightning-js] Splice manager stopped"); +} + +fn tick(node: &Node, lsp_pubkey: PublicKey, in_flight: &mut HashMap) { + let now = Instant::now(); + let onchain_balance_sats = node.list_balances().spendable_onchain_balance_sats; + let candidates: Vec = node.list_channels().iter().map(Into::into).collect(); + + advance_in_flight(in_flight, &candidates, now); + let in_flight_ucids: Vec = in_flight.keys().copied().collect(); + + let decision = decide( + onchain_balance_sats, + &candidates, + &lsp_pubkey, + &in_flight_ucids, + ); + if let Some((ucid, initial_funding_txo)) = apply(node, lsp_pubkey, decision) { + in_flight.insert( + ucid.0, + InFlight { + initial_funding_txo, + promoted_at: None, + }, + ); + } +} + +/// Advance the in-flight state machine for each tracked splice: +/// +/// * If the channel is gone (force-closed mid-splice, etc.), drop the entry — +/// there is nothing to splice into anymore. +/// * If the channel's `funding_txo` still equals the one captured when +/// `splice_in` returned `Ok`, the splice is still negotiating or awaiting +/// the LSP's `splice_locked` (which is gated on +/// `inbound_splice_minimum_depth` confirmations). Keep the entry. +/// * Once `funding_txo` flips, both sides have exchanged `splice_locked` and +/// rust-lightning has swapped the active funding scope. Stamp `promoted_at` +/// on the first observation and drop the entry once `BDK_RESYNC_GRACE` has +/// elapsed, by which point BDK's wallet sync should reflect the spent UTXO. +fn advance_in_flight( + in_flight: &mut HashMap, + channels: &[ChannelCandidate], + now: Instant, +) { + in_flight.retain(|ucid, inf| { + let Some(channel) = channels.iter().find(|c| c.user_channel_id.0 == *ucid) else { + return false; + }; + if channel.funding_txo == Some(inf.initial_funding_txo) { + // Splice still negotiating or confirming. + return true; + } + // funding_txo has flipped: splice promoted. + match inf.promoted_at { + None => { + inf.promoted_at = Some(now); + true + } + Some(t) => now.duration_since(t) < BDK_RESYNC_GRACE, + } + }); +} + +/// Pure decision: given a snapshot of the world, what should the splice +/// manager do this tick? +fn decide( + onchain_balance_sats: u64, + channels: &[ChannelCandidate], + lsp_pubkey: &PublicKey, + in_flight: &[u128], +) -> SpliceDecision { + if onchain_balance_sats == 0 { + return SpliceDecision::Skip(SkipReason::NoOnchainBalance); + } + + // Always splice into the largest LSP channel — concentrating liquidity is + // the whole point. If that single channel is mid-splice, skip the tick + // rather than fragmenting funds into a smaller one. + let Some((channel, funding_txo)) = channels + .iter() + .filter(|c| c.counterparty == *lsp_pubkey && c.is_usable) + .filter_map(|c| c.funding_txo.map(|txo| (c, txo))) + .max_by_key(|(c, _)| c.channel_value_sats) + else { + return SpliceDecision::Skip(SkipReason::NoUsableLspChannel { + onchain_balance_sats, + }); + }; + if in_flight.contains(&channel.user_channel_id.0) { + return SpliceDecision::Skip(SkipReason::SpliceAlreadyInFlight { + onchain_balance_sats, + }); + } + + SpliceDecision::Splice(SpliceCandidate { + user_channel_id: channel.user_channel_id, + channel_id: channel.channel_id.clone(), + funding_txo, + }) +} + +/// Apply a decision: log, query the maximum splice amount from the node +/// (which runs a dry-run BDK selection at the live channel funding feerate), +/// call `splice_in`. Returns `(user_channel_id, funding_txo)` when a splice +/// was successfully initiated, so the caller can seed the in-flight state +/// machine with the funding outpoint that was active at splice time. +fn apply( + node: &Node, + lsp_pubkey: PublicKey, + decision: SpliceDecision, +) -> Option<(UserChannelId, OutPoint)> { + match decision { + SpliceDecision::Skip(SkipReason::NoOnchainBalance) => None, + SpliceDecision::Skip(SkipReason::NoUsableLspChannel { + onchain_balance_sats, + }) => { + eprintln!( + "[lightning-js] Splice manager: {onchain_balance_sats} sats on-chain but no usable LSP channel; skipping" + ); + None + } + SpliceDecision::Skip(SkipReason::SpliceAlreadyInFlight { + onchain_balance_sats, + }) => { + eprintln!( + "[lightning-js] Splice manager: splice already in flight for the eligible LSP channel ({onchain_balance_sats} sats on-chain); skipping until promotion + BDK resync" + ); + None + } + SpliceDecision::Splice(SpliceCandidate { + user_channel_id, + channel_id, + funding_txo, + }) => { + let amount_sats = match node.get_max_splice_in_amount(&user_channel_id, lsp_pubkey) { + Ok(a) => a, + Err(e) => { + // Insufficient confirmed UTXOs (post-fee result is dust) is the + // common case after a small force-close sweep arrives — log and + // retry next tick. + eprintln!( + "[lightning-js] Splice manager: get_max_splice_in_amount failed on channel {channel_id}: {e}; will retry" + ); + return None; + } + }; + eprintln!( + "[lightning-js] Splice manager: splicing {amount_sats} sats into channel {channel_id}" + ); + match perform_splice(node, lsp_pubkey, user_channel_id, amount_sats) { + Ok(()) => Some((user_channel_id, funding_txo)), + Err(SpliceError::InsufficientFunds) => { + eprintln!( + "[lightning-js] Splice manager: insufficient confirmed UTXOs for {amount_sats} sats on channel {channel_id}; will retry" + ); + None + } + Err(SpliceError::ChannelNotUsable) => { + eprintln!( + "[lightning-js] Splice manager: channel {channel_id} no longer usable between decide and splice_in; will retry" + ); + None + } + Err(SpliceError::Rejected) => { + eprintln!("[lightning-js] Splice manager: splice_in rejected on channel {channel_id}"); + None + } + } + } + } +} + +/// Effectful: pre-flight `is_usable` check then delegate to ldk-node. Mirrors +/// `MdkClient::splice_in` from the mdkd source. +fn perform_splice( + node: &Node, + lsp_pubkey: PublicKey, + user_channel_id: UserChannelId, + amount_sats: u64, +) -> Result<(), SpliceError> { + let channels = node.list_channels(); + let channel = channels + .iter() + .find(|c| c.user_channel_id == user_channel_id) + .ok_or(SpliceError::ChannelNotUsable)?; + if !channel.is_usable { + return Err(SpliceError::ChannelNotUsable); + } + node + .splice_in(&user_channel_id, lsp_pubkey, amount_sats) + .map_err(map_splice_error) +} + +/// Minimal projection of `ChannelDetails` carrying only the fields the splice +/// manager's decision logic needs. Decoupling the pure decision from +/// `ChannelDetails` keeps the unit tests free of LDK type construction. +#[derive(Debug, Clone)] +struct ChannelCandidate { + user_channel_id: UserChannelId, + channel_id: String, + counterparty: PublicKey, + is_usable: bool, + funding_txo: Option, + channel_value_sats: u64, +} + +impl From<&ChannelDetails> for ChannelCandidate { + fn from(c: &ChannelDetails) -> Self { + Self { + user_channel_id: c.user_channel_id, + channel_id: c.channel_id.to_string(), + counterparty: c.counterparty_node_id, + is_usable: c.is_usable, + funding_txo: c.funding_txo, + channel_value_sats: c.channel_value_sats, + } + } +} + +/// State-machine entry tracking a splice the manager initiated. See +/// [`advance_in_flight`] for how `promoted_at` transitions. +#[derive(Debug, Clone)] +struct InFlight { + initial_funding_txo: OutPoint, + promoted_at: Option, +} + +#[derive(Debug, PartialEq, Eq)] +enum SpliceDecision { + Skip(SkipReason), + Splice(SpliceCandidate), +} + +#[derive(Debug, PartialEq, Eq)] +enum SkipReason { + NoOnchainBalance, + NoUsableLspChannel { onchain_balance_sats: u64 }, + SpliceAlreadyInFlight { onchain_balance_sats: u64 }, +} + +#[derive(Debug, PartialEq, Eq)] +struct SpliceCandidate { + user_channel_id: UserChannelId, + channel_id: String, + funding_txo: OutPoint, +} + +#[cfg(test)] +mod tests { + use super::*; + use ldk_node::bitcoin::Txid; + use ldk_node::bitcoin::hashes::Hash as _; + use std::str::FromStr; + + fn lsp() -> PublicKey { + PublicKey::from_str("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798").unwrap() + } + + fn other_peer() -> PublicKey { + PublicKey::from_str("02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5").unwrap() + } + + /// Build a deterministic `OutPoint` from a single byte so tests can express + /// "old funding" vs "new funding" without dragging in real txid construction. + fn txo(seed: u8) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([seed; 32]), + vout: 0, + } + } + + fn candidate( + counterparty: PublicKey, + is_usable: bool, + funding_txo: Option, + channel_value_sats: u64, + ucid: u128, + ) -> ChannelCandidate { + ChannelCandidate { + user_channel_id: UserChannelId(ucid), + channel_id: format!("ch{ucid}"), + counterparty, + is_usable, + funding_txo, + channel_value_sats, + } + } + + #[test] + fn skip_when_balance_zero() { + let lsp = lsp(); + let chans = vec![candidate(lsp, true, Some(txo(1)), 100_000, 1)]; + assert_eq!( + decide(0, &chans, &lsp, &[]), + SpliceDecision::Skip(SkipReason::NoOnchainBalance), + ); + } + + #[test] + fn skip_when_no_usable_lsp_channel() { + let lsp = lsp(); + let other = other_peer(); + // Wrong counterparty. + let chans = vec![candidate(other, true, Some(txo(1)), 100_000, 1)]; + assert_eq!( + decide(50_000, &chans, &lsp, &[]), + SpliceDecision::Skip(SkipReason::NoUsableLspChannel { + onchain_balance_sats: 50_000, + }), + ); + // Right peer but not usable. + let chans = vec![candidate(lsp, false, Some(txo(1)), 100_000, 1)]; + assert_eq!( + decide(50_000, &chans, &lsp, &[]), + SpliceDecision::Skip(SkipReason::NoUsableLspChannel { + onchain_balance_sats: 50_000, + }), + ); + // Right peer and usable but no funding_txo (channel still opening). + let chans = vec![candidate(lsp, true, None, 100_000, 1)]; + assert_eq!( + decide(50_000, &chans, &lsp, &[]), + SpliceDecision::Skip(SkipReason::NoUsableLspChannel { + onchain_balance_sats: 50_000, + }), + ); + } + + #[test] + fn picks_highest_capacity_lsp_channel() { + let lsp = lsp(); + let other = other_peer(); + let chans = vec![ + // Bigger but wrong peer — ignored. + candidate(other, true, Some(txo(1)), 1_000_000, 1), + // Eligible, smaller. + candidate(lsp, true, Some(txo(2)), 100_000, 2), + // Eligible, biggest — should win. + candidate(lsp, true, Some(txo(3)), 500_000, 3), + // LSP but mid-splice (is_usable false) — ignored. + candidate(lsp, false, Some(txo(4)), 750_000, 4), + ]; + let decision = decide(50_000, &chans, &lsp, &[]); + match decision { + SpliceDecision::Splice(c) => { + assert_eq!(c.user_channel_id, UserChannelId(3)); + assert_eq!(c.funding_txo, txo(3)); + } + other => panic!("expected splice, got {other:?}"), + } + } + + #[test] + fn skip_when_only_eligible_channel_is_in_flight() { + let lsp = lsp(); + let chans = vec![candidate(lsp, true, Some(txo(5)), 100_000, 5)]; + // The only eligible channel is in-flight. + assert_eq!( + decide(50_000, &chans, &lsp, &[5]), + SpliceDecision::Skip(SkipReason::SpliceAlreadyInFlight { + onchain_balance_sats: 50_000, + }), + ); + } + + #[test] + fn skip_when_largest_is_in_flight_even_if_smaller_exists() { + // Concentrating liquidity is the goal: never fall back to a smaller + // channel just because the largest is mid-splice. + let lsp = lsp(); + let chans = vec![ + candidate(lsp, true, Some(txo(1)), 500_000, 1), + candidate(lsp, true, Some(txo(2)), 200_000, 2), + ]; + assert_eq!( + decide(50_000, &chans, &lsp, &[1]), + SpliceDecision::Skip(SkipReason::SpliceAlreadyInFlight { + onchain_balance_sats: 50_000, + }), + ); + } + + fn in_flight(initial_funding_txo: OutPoint, promoted_at: Option) -> InFlight { + InFlight { + initial_funding_txo, + promoted_at, + } + } + + #[test] + fn advance_drops_entry_when_channel_is_gone() { + let mut state: HashMap = HashMap::new(); + state.insert(1, in_flight(txo(1), None)); + advance_in_flight(&mut state, &[], Instant::now()); + assert!(state.is_empty()); + } + + #[test] + fn advance_keeps_entry_while_funding_unchanged() { + // Splice is still negotiating / awaiting peer's splice_locked — funding_txo + // has not flipped yet. + let lsp = lsp(); + let mut state: HashMap = HashMap::new(); + state.insert(1, in_flight(txo(1), None)); + let chans = vec![candidate(lsp, true, Some(txo(1)), 100_000, 1)]; + advance_in_flight(&mut state, &chans, Instant::now()); + assert!(state.contains_key(&1)); + assert!(state[&1].promoted_at.is_none()); + } + + #[test] + fn advance_stamps_promotion_then_drops_after_grace() { + // After both sides splice_locked, funding_txo flips. We hold the entry for + // BDK_RESYNC_GRACE to let BDK catch up. + let lsp = lsp(); + let mut state: HashMap = HashMap::new(); + state.insert(1, in_flight(txo(1), None)); + let chans = vec![candidate(lsp, true, Some(txo(2)), 100_000, 1)]; + + let t0 = Instant::now(); + advance_in_flight(&mut state, &chans, t0); + assert_eq!(state[&1].promoted_at, Some(t0)); + + // Inside the grace window: still tracked. + advance_in_flight( + &mut state, + &chans, + t0 + BDK_RESYNC_GRACE - Duration::from_millis(1), + ); + assert!(state.contains_key(&1)); + + // Past the grace window: dropped. + advance_in_flight(&mut state, &chans, t0 + BDK_RESYNC_GRACE); + assert!(state.is_empty()); + } + + #[test] + fn advance_does_not_re_stamp_promotion() { + // Once promoted_at is set on tick N, later ticks must not reset the clock + // — otherwise the grace period would never expire. + let lsp = lsp(); + let mut state: HashMap = HashMap::new(); + let t0 = Instant::now(); + state.insert(1, in_flight(txo(1), Some(t0))); + let chans = vec![candidate(lsp, true, Some(txo(2)), 100_000, 1)]; + advance_in_flight(&mut state, &chans, t0 + Duration::from_secs(10)); + assert_eq!(state[&1].promoted_at, Some(t0)); + } +} From 305de9dba1c800a6f4c561668b5a564e4344636f Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 12 May 2026 05:36:22 -0700 Subject: [PATCH 3/4] Spawn auto-splice manager from start_receiving Wires the splice_manager module into the session lifecycle so consumers (mdk-checkout) get auto-splice without changing their nextEvent() loop. Spawn on start_receiving, cancel+join on stop_receiving and destroy. MdkNode now owns a long-lived single-worker tokio runtime built once in new() and reused across every start/stop cycle. A current-thread runtime was the first thought, but it only drives tasks while someone is actively calling block_on, so a fire-and-forget spawn would just sit there. multi_thread(1) gives us one self-driving worker thread without us having to babysit a driver thread ourselves. node is Option> so the manager can hold a refcount for the lifetime of its task. The &Node accessor stays the same via Arc deref; node_arc() hands out clones for background tasks. destroy() shuts down the splice task before dropping the Node, otherwise the manager's Arc would keep the inner Node alive past the JS object's lifetime and defeat the point of destroy(). stop_receiving cancels the token and blocks on the JoinHandle before calling node.stop(). The manager loop selects over shutdown.cancelled(), so the join returns as soon as the next poll. Doing this in the other order would let the loop observe a stopped node mid-tick. --- index.d.ts | 16 +++++++- src/lib.rs | 89 ++++++++++++++++++++++++++++++++++++++----- src/splice_manager.rs | 2 - 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/index.d.ts b/index.d.ts index 707869d..80aada0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -119,7 +119,13 @@ export declare class MdkNode { getNodeId(): string start(): void stop(): void - /** Start the node and sync wallets. Call once before polling for events. */ + /** + * Start the node and sync wallets. Call once before polling for events. + * + * If `splice.enabled` is set on construction (the default), also spawns + * the auto-splice background task on the dedicated splice runtime. The + * task is torn down by `stop_receiving` (or `destroy`). + */ startReceiving(): void /** * Get the next payment event without ACKing it. @@ -132,7 +138,13 @@ export declare class MdkNode { * Must be called after next_event() returns an event, before calling next_event() again. */ ackEvent(): void - /** Stop the node. Call when done polling. */ + /** + * Stop the node. Call when done polling. + * + * Cancels the auto-splice task (if running) and blocks until it exits + * before stopping the node, so the splice loop never observes a stopped + * node mid-tick. + */ stopReceiving(): void syncWallets(): void getBalance(): number diff --git a/src/lib.rs b/src/lib.rs index ee9c163..6fb3b83 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ use std::{ fmt::Write, str::FromStr, sync::{ - Arc, OnceLock, RwLock, + Arc, Mutex, OnceLock, RwLock, atomic::{AtomicU8, Ordering}, }, time::{Duration, Instant}, @@ -52,6 +52,8 @@ use ldk_node::{ payment::PaymentKind, }; use tokio::runtime::Runtime; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; #[macro_use] extern crate napi_derive; @@ -405,16 +407,21 @@ pub struct NodeChannel { #[napi] pub struct MdkNode { - node: Option, + node: Option>, network: Network, - /// LSP counterparty pubkey, parsed and cached from `MdkNodeOptions.lsp_node_id`. - /// Reused by the auto-splice manager to filter eligible channels by counterparty. - #[allow(dead_code)] + /// Cached LSP pubkey. Used by the splice manager to filter eligible + /// channels by counterparty. lsp_pubkey: PublicKey, - /// Resolved auto-splice manager configuration. Consumed when the manager is - /// spawned from `start_receiving()`. - #[allow(dead_code)] splice_cfg: ResolvedSpliceConfig, + /// One-worker tokio runtime dedicated to the splice manager. + splice_runtime: Runtime, + /// `Some` while a splice manager is running, `None` otherwise. + splice_task: Mutex>, +} + +struct SpliceTask { + shutdown: CancellationToken, + join: JoinHandle<()>, } #[napi] @@ -520,11 +527,23 @@ impl MdkNode { let splice_cfg = ResolvedSpliceConfig::from_options(options.splice); + // One self-driving worker is enough; the manager sleeps between ticks. + let splice_runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_name("mdk-splice") + .enable_all() + .build() + .map_err(|e| { + napi::Error::from_reason(format!("failed to build splice runtime: {e}")) + })?; + Ok(Self { - node: Some(node), + node: Some(Arc::new(node)), network, lsp_pubkey: lsp_node_id, splice_cfg, + splice_runtime, + splice_task: Mutex::new(None), }) } @@ -533,6 +552,29 @@ impl MdkNode { self.node.as_ref().expect("MdkNode has been destroyed") } + /// Clone the inner `Arc` for handing to background tasks. Panics if + /// the node has been destroyed. + fn node_arc(&self) -> Arc { + Arc::clone(self.node.as_ref().expect("MdkNode has been destroyed")) + } + + /// Cancel the splice task (if any) and block until it exits. + /// + /// Bounded by however long the in-flight `tick()` takes to return — usually + /// trivial, but a tick mid-`splice_in` is blocked on an LSP round-trip. + /// + /// Must be called from a non-tokio context (JS thread is fine); + /// `block_on` panics from inside a runtime. + fn shutdown_splice_task(&self) { + let task = self.splice_task.lock().unwrap().take(); + if let Some(SpliceTask { shutdown, join }) = task { + shutdown.cancel(); + if let Err(e) = self.splice_runtime.block_on(join) { + eprintln!("[lightning-js] Splice task ended abnormally: {e}"); + } + } + } + /// Destroy the node, dropping the inner Rust Node and its tokio runtime immediately. /// This prevents zombie processes on serverless platforms where GC is non-deterministic. /// After calling destroy(), any further method calls on this node will panic. @@ -544,6 +586,9 @@ impl MdkNode { /// them and sending a webhook. #[napi] pub fn destroy(&mut self) -> napi::Result<()> { + // Drop the splice task first so its Arc is released before we drop + // the inner Node. + self.shutdown_splice_task(); if let Some(node) = self.node.take() { node.disconnect_all_peers(); let _ = node.stop(); @@ -574,6 +619,9 @@ impl MdkNode { } /// Start the node and sync wallets. Call once before polling for events. + /// + /// If `splice.enabled` is set on construction (the default), also spawns + /// the auto-splice background task on the dedicated splice runtime. #[napi] pub fn start_receiving(&self) -> napi::Result<()> { self.node().start().map_err(|e| { @@ -585,7 +633,24 @@ impl MdkNode { eprintln!("[lightning-js] Failed to sync wallets in start_receiving: {e}"); let _ = self.node().stop(); napi::Error::from_reason(format!("Failed to sync: {e}")) - }) + })?; + + if self.splice_cfg.enabled { + // Defensive: if a prior session leaked a task (or start_receiving is + // double-invoked), cancel + join the previous one before spawning. + self.shutdown_splice_task(); + let shutdown = CancellationToken::new(); + let join = splice_manager::spawn( + self.node_arc(), + self.lsp_pubkey, + self.splice_cfg, + shutdown.clone(), + self.splice_runtime.handle(), + ); + *self.splice_task.lock().unwrap() = Some(SpliceTask { shutdown, join }); + } + + Ok(()) } /// Get the next payment event without ACKing it. @@ -697,8 +762,12 @@ impl MdkNode { } /// Stop the node. Call when done polling. + /// + /// Tears down the splice manager before stopping the node so the loop + /// never sees a stopped node mid-tick. #[napi] pub fn stop_receiving(&self) -> napi::Result<()> { + self.shutdown_splice_task(); self .node() .stop() diff --git a/src/splice_manager.rs b/src/splice_manager.rs index e4e2358..b003da7 100644 --- a/src/splice_manager.rs +++ b/src/splice_manager.rs @@ -11,8 +11,6 @@ //! * no event emission — splice activity is silent, diagnostics go to stderr //! with the existing `[lightning-js]` prefix. -#![allow(dead_code)] // Wired in commit 3. - use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; From b8279a4af6f711cd84f056d109307a67ad91b070 Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 12 May 2026 05:36:50 -0700 Subject: [PATCH 4/4] Tighten comments --- index.d.ts | 8 ++-- src/splice_manager.rs | 89 +++++++++++++------------------------------ 2 files changed, 30 insertions(+), 67 deletions(-) diff --git a/index.d.ts b/index.d.ts index 80aada0..1399fad 100644 --- a/index.d.ts +++ b/index.d.ts @@ -123,8 +123,7 @@ export declare class MdkNode { * Start the node and sync wallets. Call once before polling for events. * * If `splice.enabled` is set on construction (the default), also spawns - * the auto-splice background task on the dedicated splice runtime. The - * task is torn down by `stop_receiving` (or `destroy`). + * the auto-splice background task on the dedicated splice runtime. */ startReceiving(): void /** @@ -141,9 +140,8 @@ export declare class MdkNode { /** * Stop the node. Call when done polling. * - * Cancels the auto-splice task (if running) and blocks until it exits - * before stopping the node, so the splice loop never observes a stopped - * node mid-tick. + * Tears down the splice manager before stopping the node so the loop + * never sees a stopped node mid-tick. */ stopReceiving(): void syncWallets(): void diff --git a/src/splice_manager.rs b/src/splice_manager.rs index b003da7..5dda2c8 100644 --- a/src/splice_manager.rs +++ b/src/splice_manager.rs @@ -1,15 +1,10 @@ -//! Auto-splice manager: a background task that, when the node holds confirmed -//! on-chain funds AND has a usable LSP channel, splices the on-chain balance -//! into the largest such channel. Consolidates liquidity that would otherwise -//! sit idle after a JIT channel closes and a fresh JIT channel opens for the -//! same wallet. +//! Background task that splices confirmed on-chain funds into the largest +//! usable LSP channel. Consolidates liquidity that would otherwise sit idle +//! after one JIT channel closes and a fresh one opens for the same wallet. //! -//! Ported from `mdkd::mdk::splice_manager`. The pure decision logic (`decide`, -//! `advance_in_flight`) is identical to the source; the effectful shell is -//! adapted to lightning-js conventions: -//! * no `MdkClient` — takes `Arc` + `PublicKey` (LSP) directly; -//! * no event emission — splice activity is silent, diagnostics go to stderr -//! with the existing `[lightning-js]` prefix. +//! Ported from `mdkd::mdk::splice_manager`. `decide` and `advance_in_flight` +//! are byte-identical; the effectful shell takes `Arc` + LSP pubkey +//! directly (no `MdkClient`) and logs to stderr instead of emitting events. use std::collections::HashMap; use std::sync::Arc; @@ -24,32 +19,22 @@ use tokio_util::sync::CancellationToken; use crate::ResolvedSpliceConfig; -/// Once a splice has been promoted (both sides exchanged `splice_locked` and -/// `ChannelDetails::funding_txo` flips to the new outpoint), we keep the -/// channel out of the candidate pool for this long. ldk-node's background -/// `continuously_sync_wallets` task reruns every ~60s; inside that window -/// `list_balances().spendable_onchain_balance_sats` still reports the -/// pre-splice value (BDK has not yet picked up that the UTXO is spent), so a -/// tick that lands here would happily re-fire on the same UTXO. This grace -/// period bridges the gap between promotion and the next BDK sync. +/// After a splice promotes (funding_txo flips), BDK's wallet sync still +/// reports the pre-splice balance for up to ~60s. Hold the channel out of +/// the candidate pool for this long to avoid re-firing on the same UTXO. const BDK_RESYNC_GRACE: Duration = Duration::from_secs(60); -/// Typed splice failure modes. Modeled as an ADT so the apply layer can -/// pattern-match on the failure mode without inspecting strings. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum SpliceError { - /// The target channel exists but is not currently usable (mid-splice, peer - /// disconnected, mid-monitor-update). Skip this tick and try again. + /// Channel exists but isn't usable right now (mid-splice, peer down, + /// mid-monitor-update). Retry next tick. ChannelNotUsable, - /// BDK selection failed because the post-fee result is below the dust - /// limit. Same retry semantics as `ChannelNotUsable`. + /// Post-fee splice amount is below dust. Retry next tick. InsufficientFunds, - /// Catch-all for any other `NodeError` returned by `Node::splice_in`. + /// Any other `NodeError` from `Node::splice_in`. Rejected, } -/// `NodeError::InsufficientFunds` is the one case the splice manager treats -/// specially (silent retry). Everything else collapses to `Rejected`. fn map_splice_error(e: NodeError) -> SpliceError { match e { NodeError::InsufficientFunds => SpliceError::InsufficientFunds, @@ -116,18 +101,9 @@ fn tick(node: &Node, lsp_pubkey: PublicKey, in_flight: &mut HashMap, channels: &[ChannelCandidate], @@ -152,8 +128,7 @@ fn advance_in_flight( }); } -/// Pure decision: given a snapshot of the world, what should the splice -/// manager do this tick? +/// Pure decision over a tick's snapshot. fn decide( onchain_balance_sats: u64, channels: &[ChannelCandidate], @@ -164,9 +139,8 @@ fn decide( return SpliceDecision::Skip(SkipReason::NoOnchainBalance); } - // Always splice into the largest LSP channel — concentrating liquidity is - // the whole point. If that single channel is mid-splice, skip the tick - // rather than fragmenting funds into a smaller one. + // Largest LSP channel only. Fragmenting into a smaller one when the + // largest is mid-splice would defeat the consolidation goal. let Some((channel, funding_txo)) = channels .iter() .filter(|c| c.counterparty == *lsp_pubkey && c.is_usable) @@ -190,11 +164,9 @@ fn decide( }) } -/// Apply a decision: log, query the maximum splice amount from the node -/// (which runs a dry-run BDK selection at the live channel funding feerate), -/// call `splice_in`. Returns `(user_channel_id, funding_txo)` when a splice -/// was successfully initiated, so the caller can seed the in-flight state -/// machine with the funding outpoint that was active at splice time. +/// Effectful: log, size the splice via `get_max_splice_in_amount`, call +/// `splice_in`. Returns `(ucid, funding_txo)` on success so the caller can +/// seed the in-flight entry with the pre-splice funding outpoint. fn apply( node: &Node, lsp_pubkey: PublicKey, @@ -226,9 +198,7 @@ fn apply( let amount_sats = match node.get_max_splice_in_amount(&user_channel_id, lsp_pubkey) { Ok(a) => a, Err(e) => { - // Insufficient confirmed UTXOs (post-fee result is dust) is the - // common case after a small force-close sweep arrives — log and - // retry next tick. + // Common case: post-fee amount is dust. Retry next tick. eprintln!( "[lightning-js] Splice manager: get_max_splice_in_amount failed on channel {channel_id}: {e}; will retry" ); @@ -261,8 +231,7 @@ fn apply( } } -/// Effectful: pre-flight `is_usable` check then delegate to ldk-node. Mirrors -/// `MdkClient::splice_in` from the mdkd source. +/// Pre-flight `is_usable` check, then delegate to ldk-node. fn perform_splice( node: &Node, lsp_pubkey: PublicKey, @@ -282,9 +251,8 @@ fn perform_splice( .map_err(map_splice_error) } -/// Minimal projection of `ChannelDetails` carrying only the fields the splice -/// manager's decision logic needs. Decoupling the pure decision from -/// `ChannelDetails` keeps the unit tests free of LDK type construction. +/// Projection of `ChannelDetails` carrying only what `decide` needs. Keeps +/// unit tests free of LDK type construction. #[derive(Debug, Clone)] struct ChannelCandidate { user_channel_id: UserChannelId, @@ -308,8 +276,7 @@ impl From<&ChannelDetails> for ChannelCandidate { } } -/// State-machine entry tracking a splice the manager initiated. See -/// [`advance_in_flight`] for how `promoted_at` transitions. +/// In-flight entry. See [`advance_in_flight`] for the `promoted_at` semantics. #[derive(Debug, Clone)] struct InFlight { initial_funding_txo: OutPoint, @@ -351,8 +318,6 @@ mod tests { PublicKey::from_str("02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5").unwrap() } - /// Build a deterministic `OutPoint` from a single byte so tests can express - /// "old funding" vs "new funding" without dragging in real txid construction. fn txo(seed: u8) -> OutPoint { OutPoint { txid: Txid::from_byte_array([seed; 32]),