@@ -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} ;
5454use tokio:: runtime:: Runtime ;
55+ use tokio:: task:: JoinHandle ;
56+ use tokio_util:: sync:: CancellationToken ;
5557
5658#[ macro_use]
5759extern crate napi_derive;
5860
61+ mod splice_manager;
62+
5963/// Polling interval for event loops and state checks.
6064const 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]
367409pub 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