Skip to content

Commit 4456df8

Browse files
authored
Auto-splice on-chain funds into the LSP channel (#38)
Ports the auto-splice manager from mdkd to lightning-js so JS consumers (mdk-checkout) get liquidity consolidation without any changes on their side. After a JIT channel closes and a fresh one opens for the same wallet, the sweep sits on-chain and is useless for routing until something splices it back into a channel. This manager does that.
1 parent ecf4b4c commit 4456df8

4 files changed

Lines changed: 657 additions & 7 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ bitcoin-payment-instructions = { version = "0.5.0", default-features = false, fe
1515
"http",
1616
] }
1717
# Branch: https://github.com/moneydevkit/ldk-node/commits/lsp-0.7.0_accept-underpaying-htlcs_with_timing_logs
18-
ldk-node = { default-features = false, git = "https://github.com/moneydevkit/ldk-node.git", rev = "5baa1f83a13407818b069b1f990157c8761eb982" }
18+
ldk-node = { default-features = false, git = "https://github.com/moneydevkit/ldk-node.git", rev = "5dce44b6e795560bbf62f49d3648308ce88a0586" }
1919
#ldk-node = { path = "../ldk-node" }
2020

2121
napi = { version = "2", features = ["napi4"] }
2222
napi-derive = "2"
2323
tokio = { version = "1", features = ["rt-multi-thread"] }
24+
tokio-util = { version = "0.7", default-features = false }
2425
writeable = { version = "=0.6.2", features = ["alloc"] }
2526

2627
[build-dependencies]

index.d.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ export interface MdkNodeOptions {
4040
lspNodeId: string
4141
lspAddress: string
4242
scoringParamOverrides?: ScoringParamOverrides
43+
splice?: SpliceConfig
44+
}
45+
/**
46+
* Configuration for the auto-splice manager. The manager wakes up every
47+
* `poll_interval_secs`, reads the spendable on-chain balance, and splices it
48+
* into the largest usable LSP channel when one is available.
49+
*/
50+
export interface SpliceConfig {
51+
/** Enable the auto-splice background manager. Default: true. */
52+
enabled?: boolean
53+
/** Poll interval in seconds. Default: 30. */
54+
pollIntervalSecs?: number
4355
}
4456
export interface PaymentMetadata {
4557
bolt11: string
@@ -107,7 +119,12 @@ export declare class MdkNode {
107119
getNodeId(): string
108120
start(): void
109121
stop(): void
110-
/** Start the node and sync wallets. Call once before polling for events. */
122+
/**
123+
* Start the node and sync wallets. Call once before polling for events.
124+
*
125+
* If `splice.enabled` is set on construction (the default), also spawns
126+
* the auto-splice background task on the dedicated splice runtime.
127+
*/
111128
startReceiving(): void
112129
/**
113130
* Get the next payment event without ACKing it.
@@ -120,7 +137,12 @@ export declare class MdkNode {
120137
* Must be called after next_event() returns an event, before calling next_event() again.
121138
*/
122139
ackEvent(): void
123-
/** Stop the node. Call when done polling. */
140+
/**
141+
* Stop the node. Call when done polling.
142+
*
143+
* Tears down the splice manager before stopping the node so the loop
144+
* never sees a stopped node mid-tick.
145+
*/
124146
stopReceiving(): void
125147
syncWallets(): void
126148
getBalance(): number

src/lib.rs

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use std::{
1313
fmt::Write,
1414
str::FromStr,
1515
sync::{
16-
Arc, OnceLock, RwLock,
16+
Arc, Mutex, OnceLock, RwLock,
1717
atomic::{AtomicU8, Ordering},
1818
},
1919
time::{Duration, Instant},
@@ -52,10 +52,14 @@ use ldk_node::{
5252
payment::PaymentKind,
5353
};
5454
use tokio::runtime::Runtime;
55+
use tokio::task::JoinHandle;
56+
use tokio_util::sync::CancellationToken;
5557

5658
#[macro_use]
5759
extern crate napi_derive;
5860

61+
mod splice_manager;
62+
5963
/// Polling interval for event loops and state checks.
6064
const POLL_INTERVAL: Duration = Duration::from_millis(10);
6165

@@ -296,6 +300,44 @@ pub struct MdkNodeOptions {
296300
pub lsp_node_id: String,
297301
pub lsp_address: String,
298302
pub scoring_param_overrides: Option<ScoringParamOverrides>,
303+
pub splice: Option<SpliceConfig>,
304+
}
305+
306+
/// Configuration for the auto-splice manager. The manager wakes up every
307+
/// `poll_interval_secs`, reads the spendable on-chain balance, and splices it
308+
/// into the largest usable LSP channel when one is available.
309+
#[napi(object)]
310+
pub struct SpliceConfig {
311+
/// Enable the auto-splice background manager. Default: true.
312+
pub enabled: Option<bool>,
313+
/// Poll interval in seconds. Default: 30.
314+
pub poll_interval_secs: Option<u32>,
315+
}
316+
317+
/// Resolved splice config with defaults applied. Internal.
318+
#[derive(Debug, Clone, Copy)]
319+
pub(crate) struct ResolvedSpliceConfig {
320+
pub(crate) enabled: bool,
321+
pub(crate) poll_interval: Duration,
322+
}
323+
324+
impl ResolvedSpliceConfig {
325+
fn from_options(cfg: Option<SpliceConfig>) -> Self {
326+
let default = Self {
327+
enabled: true,
328+
poll_interval: Duration::from_secs(30),
329+
};
330+
match cfg {
331+
None => default,
332+
Some(c) => Self {
333+
enabled: c.enabled.unwrap_or(default.enabled),
334+
poll_interval: c
335+
.poll_interval_secs
336+
.map(|s| Duration::from_secs(s.max(1) as u64))
337+
.unwrap_or(default.poll_interval),
338+
},
339+
}
340+
}
299341
}
300342

301343
#[napi(object)]
@@ -365,8 +407,21 @@ pub struct NodeChannel {
365407

366408
#[napi]
367409
pub struct MdkNode {
368-
node: Option<Node>,
410+
node: Option<Arc<Node>>,
369411
network: Network,
412+
/// Cached LSP pubkey. Used by the splice manager to filter eligible
413+
/// channels by counterparty.
414+
lsp_pubkey: PublicKey,
415+
splice_cfg: ResolvedSpliceConfig,
416+
/// One-worker tokio runtime dedicated to the splice manager.
417+
splice_runtime: Runtime,
418+
/// `Some` while a splice manager is running, `None` otherwise.
419+
splice_task: Mutex<Option<SpliceTask>>,
420+
}
421+
422+
struct SpliceTask {
423+
shutdown: CancellationToken,
424+
join: JoinHandle<()>,
370425
}
371426

372427
#[napi]
@@ -470,9 +525,23 @@ impl MdkNode {
470525
.build_with_vss_store_and_fixed_headers(options.vss_url, vss_identifier, vss_headers)
471526
.map_err(|err| napi::Error::from_reason(err.to_string()))?;
472527

528+
let splice_cfg = ResolvedSpliceConfig::from_options(options.splice);
529+
530+
// One self-driving worker is enough; the manager sleeps between ticks.
531+
let splice_runtime = tokio::runtime::Builder::new_multi_thread()
532+
.worker_threads(1)
533+
.thread_name("mdk-splice")
534+
.enable_all()
535+
.build()
536+
.map_err(|e| napi::Error::from_reason(format!("failed to build splice runtime: {e}")))?;
537+
473538
Ok(Self {
474-
node: Some(node),
539+
node: Some(Arc::new(node)),
475540
network,
541+
lsp_pubkey: lsp_node_id,
542+
splice_cfg,
543+
splice_runtime,
544+
splice_task: Mutex::new(None),
476545
})
477546
}
478547

@@ -481,6 +550,29 @@ impl MdkNode {
481550
self.node.as_ref().expect("MdkNode has been destroyed")
482551
}
483552

553+
/// Clone the inner `Arc<Node>` for handing to background tasks. Panics if
554+
/// the node has been destroyed.
555+
fn node_arc(&self) -> Arc<Node> {
556+
Arc::clone(self.node.as_ref().expect("MdkNode has been destroyed"))
557+
}
558+
559+
/// Cancel the splice task (if any) and block until it exits.
560+
///
561+
/// Bounded by however long the in-flight `tick()` takes to return — usually
562+
/// trivial, but a tick mid-`splice_in` is blocked on an LSP round-trip.
563+
///
564+
/// Must be called from a non-tokio context (JS thread is fine);
565+
/// `block_on` panics from inside a runtime.
566+
fn shutdown_splice_task(&self) {
567+
let task = self.splice_task.lock().unwrap().take();
568+
if let Some(SpliceTask { shutdown, join }) = task {
569+
shutdown.cancel();
570+
if let Err(e) = self.splice_runtime.block_on(join) {
571+
eprintln!("[lightning-js] Splice task ended abnormally: {e}");
572+
}
573+
}
574+
}
575+
484576
/// Destroy the node, dropping the inner Rust Node and its tokio runtime immediately.
485577
/// This prevents zombie processes on serverless platforms where GC is non-deterministic.
486578
/// After calling destroy(), any further method calls on this node will panic.
@@ -492,6 +584,9 @@ impl MdkNode {
492584
/// them and sending a webhook.
493585
#[napi]
494586
pub fn destroy(&mut self) -> napi::Result<()> {
587+
// Drop the splice task first so its Arc<Node> is released before we drop
588+
// the inner Node.
589+
self.shutdown_splice_task();
495590
if let Some(node) = self.node.take() {
496591
node.disconnect_all_peers();
497592
let _ = node.stop();
@@ -515,13 +610,17 @@ impl MdkNode {
515610

516611
#[napi]
517612
pub fn stop(&self) {
613+
self.shutdown_splice_task();
518614
if let Err(err) = self.node().stop() {
519615
eprintln!("[lightning-js] Failed to stop node via stop(): {err}");
520616
panic!("failed to stop node: {err}");
521617
}
522618
}
523619

524620
/// Start the node and sync wallets. Call once before polling for events.
621+
///
622+
/// If `splice.enabled` is set on construction (the default), also spawns
623+
/// the auto-splice background task on the dedicated splice runtime.
525624
#[napi]
526625
pub fn start_receiving(&self) -> napi::Result<()> {
527626
self.node().start().map_err(|e| {
@@ -533,7 +632,24 @@ impl MdkNode {
533632
eprintln!("[lightning-js] Failed to sync wallets in start_receiving: {e}");
534633
let _ = self.node().stop();
535634
napi::Error::from_reason(format!("Failed to sync: {e}"))
536-
})
635+
})?;
636+
637+
if self.splice_cfg.enabled {
638+
// Defensive: if a prior session leaked a task (or start_receiving is
639+
// double-invoked), cancel + join the previous one before spawning.
640+
self.shutdown_splice_task();
641+
let shutdown = CancellationToken::new();
642+
let join = splice_manager::spawn(
643+
self.node_arc(),
644+
self.lsp_pubkey,
645+
self.splice_cfg,
646+
shutdown.clone(),
647+
self.splice_runtime.handle(),
648+
);
649+
*self.splice_task.lock().unwrap() = Some(SpliceTask { shutdown, join });
650+
}
651+
652+
Ok(())
537653
}
538654

539655
/// Get the next payment event without ACKing it.
@@ -645,8 +761,12 @@ impl MdkNode {
645761
}
646762

647763
/// Stop the node. Call when done polling.
764+
///
765+
/// Tears down the splice manager before stopping the node so the loop
766+
/// never sees a stopped node mid-tick.
648767
#[napi]
649768
pub fn stop_receiving(&self) -> napi::Result<()> {
769+
self.shutdown_splice_task();
650770
self
651771
.node()
652772
.stop()

0 commit comments

Comments
 (0)