diff --git a/src/daemon/api/error.rs b/src/daemon/api/error.rs index 905c06e..76ae276 100644 --- a/src/daemon/api/error.rs +++ b/src/daemon/api/error.rs @@ -21,6 +21,7 @@ impl From for AppError { MdkError::Node(msg) => AppError::Internal(msg), MdkError::Platform { message, .. } => AppError::Internal(message), MdkError::Network(msg) => AppError::Internal(msg), + MdkError::Splice(e) => AppError::Internal(e.to_string()), } } } diff --git a/src/daemon/config.rs b/src/daemon/config.rs index 700229c..f4b06e6 100644 --- a/src/daemon/config.rs +++ b/src/daemon/config.rs @@ -2,6 +2,7 @@ use std::io; use std::net::SocketAddr; use std::path::PathBuf; use std::str::FromStr; +use std::time::Duration; use ldk_node::bitcoin::Network; use ldk_node::lightning::ln::msgs::SocketAddress; @@ -10,11 +11,14 @@ use log::LevelFilter; use mdk::node::ScoringOverrides; use serde::Deserialize; +use mdk::node::SpliceConfig; + #[derive(Deserialize)] struct TomlConfig { node: Option, storage: Option, log: Option, + splice: Option, } #[derive(Deserialize)] @@ -80,6 +84,12 @@ struct LogSection { file: Option, } +#[derive(Deserialize)] +struct SpliceSection { + enabled: Option, + poll_interval_secs: Option, +} + pub struct MdkConfig { pub network: Network, pub listening_addrs: Option>, @@ -90,6 +100,7 @@ pub struct MdkConfig { pub log_level: LevelFilter, pub pathfinding_scores_source_url: Option, pub scoring_overrides: ScoringOverrides, + pub splice: SpliceConfig, } pub fn load_config(path: &str) -> io::Result { @@ -148,6 +159,19 @@ pub fn load_config(path: &str) -> io::Result { }; let scoring_overrides = node.scoring.map(ScoringOverrides::from).unwrap_or_default(); + let splice = match toml.splice { + Some(s) => { + let defaults = SpliceConfig::default(); + SpliceConfig { + enabled: s.enabled.unwrap_or(defaults.enabled), + poll_interval: s + .poll_interval_secs + .map(Duration::from_secs) + .unwrap_or(defaults.poll_interval), + } + } + None => SpliceConfig::default(), + }; Ok(MdkConfig { network, @@ -159,6 +183,7 @@ pub fn load_config(path: &str) -> io::Result { log_level, pathfinding_scores_source_url: node.pathfinding_scores_source_url, scoring_overrides, + splice, }) } diff --git a/src/main.rs b/src/main.rs index eff75b6..7d74414 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,6 +132,7 @@ fn main() { mnemonic: mnemonic_phrase, infra, scoring_overrides: config_file.scoring_overrides, + splice: config_file.splice, }; // Separate HTTP client for daemon concerns (webhooks, expiry monitor). diff --git a/src/mdk/client.rs b/src/mdk/client.rs index ac7da01..a74b816 100644 --- a/src/mdk/client.rs +++ b/src/mdk/client.rs @@ -1,24 +1,27 @@ +use std::str::FromStr; use std::sync::Arc; use chrono::{DateTime, SecondsFormat}; use ldk_node::bitcoin::hashes::sha256; use ldk_node::bitcoin::hashes::Hash as _; +use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::lightning::ln::channelmanager::PaymentId; use ldk_node::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description, Sha256}; -use ldk_node::{Event, Node}; -use log::{error, info}; +use ldk_node::{Event, Node, NodeError, UserChannelId}; +use log::{error, info, warn}; use reqwest::{Client, Proxy}; use tokio::runtime::Handle; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; -use crate::mdk::error::MdkError; +use crate::mdk::error::{MdkError, SpliceError}; use crate::mdk::mdk_api::client::MdkApiClient; use crate::mdk::mdk_api::types::{ CheckoutCustomer, CreateCheckoutRequest, PaymentEntry, PaymentReceivedRequest, RegisterInvoiceRequest, }; -use crate::mdk::node::{build_node, NodeConfig}; +use crate::mdk::node::{build_node, NodeConfig, SpliceConfig}; +use crate::mdk::splice_manager; use crate::mdk::types::{CheckoutResult, CreateCheckoutParams, InvoiceDescription, MdkEvent}; const DEFAULT_EXPIRY_SECS: u32 = 3600; @@ -32,6 +35,8 @@ pub type EventHandler = Arc; pub struct MdkClient { node: Arc, api: Arc, + lsp_pubkey: PublicKey, + splice_cfg: SpliceConfig, event_tx: broadcast::Sender, event_handler: Option, shutdown: CancellationToken, @@ -69,6 +74,9 @@ impl MdkClient { let api_base_url = config.infra.mdk_api_base_url.clone(); let socks_proxy = config.socks_proxy.clone(); + let lsp_pubkey = PublicKey::from_str(&config.infra.lsp_node_id) + .map_err(|e| MdkError::InvalidInput(format!("bad lsp_node_id: {e}")))?; + let splice_cfg = config.splice.clone(); let node = build_node(config, handle.clone())?; let http_client = build_http_client(socks_proxy.as_deref())?; @@ -82,6 +90,8 @@ impl MdkClient { Ok(Self { node, api, + lsp_pubkey, + splice_cfg, event_tx, event_handler, shutdown: CancellationToken::new(), @@ -97,6 +107,14 @@ impl MdkClient { self.handle.spawn(async move { this.run_event_loop().await; }); + if self.splice_cfg.enabled { + splice_manager::spawn( + Arc::clone(self), + self.splice_cfg.clone(), + self.shutdown.clone(), + &self.handle, + ); + } Ok(()) } @@ -115,10 +133,64 @@ impl MdkClient { Arc::clone(&self.node) } + pub fn lsp_pubkey(&self) -> PublicKey { + self.lsp_pubkey + } + + /// Splice `amount_sats` of confirmed on-chain funds into the + /// existing channel identified by `user_channel_id`, with the + /// LSP as counterparty. + /// + /// Validates locally that the channel exists and is usable + /// before delegating to ldk-node. ldk-node's splice errors are + /// mapped to typed `SpliceError` variants so callers (notably + /// the splice manager) can pattern-match on the failure mode + /// without inspecting strings. + pub fn splice_in( + &self, + user_channel_id: UserChannelId, + amount_sats: u64, + ) -> Result<(), MdkError> { + if amount_sats == 0 { + return Err(MdkError::InvalidInput( + "splice amount must be greater than zero".into(), + )); + } + + let channels = self.node.list_channels(); + let channel = channels + .iter() + .find(|c| c.user_channel_id == user_channel_id) + .ok_or_else(|| { + MdkError::NotFound(format!( + "channel with user_channel_id {}", + user_channel_id.0 + )) + })?; + + if !channel.is_usable { + return Err(MdkError::Splice(SpliceError::ChannelNotUsable)); + } + + self.node + .splice_in(&user_channel_id, self.lsp_pubkey, amount_sats) + .map_err(map_splice_error) + } + pub fn subscribe(&self) -> broadcast::Receiver { self.event_tx.subscribe() } + /// Fan an event out to the configured handler and broadcast + /// subscribers. Used by the LDK event loop and the splice manager + /// to surface internally-generated events. + pub fn emit_event(&self, ev: MdkEvent) { + if let Some(handler) = &self.event_handler { + handler(ev.clone()); + } + let _ = self.event_tx.send(ev); + } + async fn run_event_loop(&self) { loop { tokio::select! { @@ -131,10 +203,7 @@ impl MdkClient { } if let Some(ev) = mdk_event { - if let Some(handler) = &self.event_handler { - handler(ev.clone()); - } - let _ = self.event_tx.send(ev); + self.emit_event(ev); } } } @@ -225,6 +294,35 @@ impl MdkClient { fee_earned_sats: total_fee_earned_msat.map(|m| m / 1000), }) } + Event::SplicePending { + channel_id, + new_funding_txo, + .. + } => { + let cid = channel_id.to_string(); + let txid = new_funding_txo.txid.to_string(); + info!("SPLICE_PENDING: channel {cid}, new funding tx {txid}"); + Some(MdkEvent::SplicePending { + channel_id: cid, + new_funding_txid: txid, + }) + } + Event::SpliceFailed { + channel_id, + abandoned_funding_txo, + .. + } => { + let cid = channel_id.to_string(); + let reason = match abandoned_funding_txo { + Some(txo) => format!("abandoned splice tx {}", txo.txid), + None => "splice abandoned before tx broadcast".to_string(), + }; + warn!("SPLICE_FAILED: channel {cid}, {reason}"); + Some(MdkEvent::SpliceFailed { + channel_id: cid, + reason, + }) + } _ => None, } } @@ -374,3 +472,17 @@ fn format_payment_id(id: &Option) -> String { None => "unknown".into(), } } + +/// Map an ldk-node error returned from a splice call into a typed +/// `MdkError::Splice`. Kept as a free helper (rather than a +/// `From` impl) because `NodeError::InsufficientFunds` +/// is also produced by `open_channel` and on-chain wallet paths, +/// where mapping it to a splice variant would be wrong. Anything +/// other than `InsufficientFunds` collapses to `Rejected` — the +/// splice manager treats the catch-all bucket uniformly. +fn map_splice_error(e: NodeError) -> MdkError { + match e { + NodeError::InsufficientFunds => MdkError::Splice(SpliceError::InsufficientFunds), + _ => MdkError::Splice(SpliceError::Rejected), + } +} diff --git a/src/mdk/error.rs b/src/mdk/error.rs index ae72fb9..0823adf 100644 --- a/src/mdk/error.rs +++ b/src/mdk/error.rs @@ -13,6 +13,36 @@ pub enum MdkError { }, Network(String), NotFound(String), + Splice(SpliceError), +} + +/// Typed splice failure modes. Modeled as an ADT so the splice +/// manager can decide what to do (skip vs. emit failure event) +/// without inspecting log strings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpliceError { + /// The target channel exists but is not currently usable + /// (mid-splice, peer disconnected, mid-monitor-update). The + /// splice manager should skip this tick and try again. + ChannelNotUsable, + /// The on-chain wallet does not have enough confirmed funds + /// for the requested splice amount. Retried next tick. + InsufficientFunds, + /// ldk-node refused the splice (coin selection failed under + /// fee pressure, channel not yet ready, peer rejected, etc.). + /// ldk-node currently collapses these into one error variant; + /// we do too. + Rejected, +} + +impl fmt::Display for SpliceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SpliceError::ChannelNotUsable => write!(f, "channel not usable"), + SpliceError::InsufficientFunds => write!(f, "insufficient confirmed on-chain funds"), + SpliceError::Rejected => write!(f, "splice rejected by ldk-node"), + } + } } impl fmt::Display for MdkError { @@ -27,6 +57,7 @@ impl fmt::Display for MdkError { } => write!(f, "platform API error ({status}): [{code}] {message}"), MdkError::Network(msg) => write!(f, "network error: {msg}"), MdkError::NotFound(msg) => write!(f, "not found: {msg}"), + MdkError::Splice(e) => write!(f, "splice error: {e}"), } } } diff --git a/src/mdk/mod.rs b/src/mdk/mod.rs index a8981b8..5b3adaa 100644 --- a/src/mdk/mod.rs +++ b/src/mdk/mod.rs @@ -3,4 +3,5 @@ pub mod config; pub mod error; pub mod mdk_api; pub mod node; +pub mod splice_manager; pub mod types; diff --git a/src/mdk/node.rs b/src/mdk/node.rs index 3e2e697..2744e5d 100644 --- a/src/mdk/node.rs +++ b/src/mdk/node.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::net::ToSocketAddrs; use std::str::FromStr; use std::sync::Arc; +use std::time::Duration; use ldk_node::bip39::Mnemonic; use ldk_node::bitcoin::hashes::sha256; @@ -30,6 +31,7 @@ pub struct NodeConfig { pub mnemonic: String, pub infra: NetworkInfra, pub scoring_overrides: ScoringOverrides, + pub splice: SpliceConfig, } /// Per-field overrides for the probabilistic scorer's fee parameters. @@ -77,6 +79,24 @@ impl ScoringOverrides { } } +/// Configuration for the auto-splice manager. The manager wakes up +/// every `poll_interval`, reads the spendable on-chain balance, and +/// splices it into an existing LSP channel when one is available. +#[derive(Debug, Clone)] +pub struct SpliceConfig { + pub enabled: bool, + pub poll_interval: Duration, +} + +impl Default for SpliceConfig { + fn default() -> Self { + Self { + enabled: true, + poll_interval: Duration::from_secs(30), + } + } +} + pub fn build_node( config: NodeConfig, runtime: tokio::runtime::Handle, diff --git a/src/mdk/splice_manager.rs b/src/mdk/splice_manager.rs new file mode 100644 index 0000000..93e0a5f --- /dev/null +++ b/src/mdk/splice_manager.rs @@ -0,0 +1,491 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::bitcoin::OutPoint; +use ldk_node::{ChannelDetails, UserChannelId}; +use log::{debug, info, warn}; +use tokio::runtime::Handle; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::mdk::client::MdkClient; +use crate::mdk::error::{MdkError, SpliceError}; +use crate::mdk::node::SpliceConfig; +use crate::mdk::types::MdkEvent; + +/// 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); + +pub fn spawn( + client: Arc, + cfg: SpliceConfig, + shutdown: CancellationToken, + handle: &Handle, +) -> JoinHandle<()> { + handle.spawn(async move { + run(client, cfg, shutdown).await; + }) +} + +async fn run(client: Arc, cfg: SpliceConfig, shutdown: CancellationToken) { + info!( + "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(&client, &mut in_flight); + } + } + } + info!("Splice manager stopped"); +} + +fn tick(client: &MdkClient, in_flight: &mut HashMap) { + let now = Instant::now(); + let node = client.node(); + let onchain_balance_sats = node.list_balances().spendable_onchain_balance_sats; + let candidates: Vec = node.list_channels().iter().map(Into::into).collect(); + let lsp_pubkey = client.lsp_pubkey(); + + 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(client, 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`, fan events out. 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(client: &MdkClient, decision: SpliceDecision) -> Option<(UserChannelId, OutPoint)> { + match decision { + SpliceDecision::Skip(SkipReason::NoOnchainBalance) => None, + SpliceDecision::Skip(SkipReason::NoUsableLspChannel { + onchain_balance_sats, + }) => { + debug!( + "Splice manager: {onchain_balance_sats} sats on-chain but no usable LSP channel; skipping" + ); + None + } + SpliceDecision::Skip(SkipReason::SpliceAlreadyInFlight { + onchain_balance_sats, + }) => { + debug!( + "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 client + .node() + .get_max_splice_in_amount(&user_channel_id, client.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 at debug and + // retry next tick. + debug!( + "Splice manager: get_max_splice_in_amount failed on channel {channel_id}: {e}; will retry" + ); + return None; + } + }; + info!("Splice manager: splicing {amount_sats} sats into channel {channel_id}"); + match client.splice_in(user_channel_id, amount_sats) { + Ok(()) => { + client.emit_event(MdkEvent::SpliceInitiated { channel_id }); + Some((user_channel_id, funding_txo)) + } + Err(MdkError::Splice(SpliceError::InsufficientFunds)) => { + debug!( + "Splice manager: insufficient confirmed UTXOs for {amount_sats} sats on channel {channel_id}; will retry" + ); + None + } + Err(e) => { + warn!("Splice manager: splice_in failed on channel {channel_id}: {e}"); + client.emit_event(MdkEvent::SpliceFailed { + channel_id, + reason: e.to_string(), + }); + None + } + } + } + } +} + +/// 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::hashes::Hash as _; + use ldk_node::bitcoin::Txid; + 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)); + } +} diff --git a/src/mdk/types.rs b/src/mdk/types.rs index 01b509b..8a307de 100644 --- a/src/mdk/types.rs +++ b/src/mdk/types.rs @@ -103,6 +103,24 @@ pub enum MdkEvent { PaymentForwarded { fee_earned_sats: Option, }, + /// The splice manager has called `splice_in` on a channel and + /// ldk-node accepted the request. Funding-tx broadcast follows. + SpliceInitiated { + channel_id: String, + }, + /// A splice transaction has been broadcast and is awaiting + /// on-chain confirmation. Mirrors `ldk_node::Event::SplicePending`. + SplicePending { + channel_id: String, + new_funding_txid: String, + }, + /// A splice attempt failed — either synchronously from + /// `splice_in` (peer offline, fee too low, etc.) or after + /// negotiation via `ldk_node::Event::SpliceFailed`. + SpliceFailed { + channel_id: String, + reason: String, + }, } pub struct PaymentResult { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 12f3e24..4a85151 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -133,6 +133,10 @@ dir_path = "{storage_dir}" rpc_address = "{rpc_address}" rpc_user = "{rpc_user}" rpc_password = "{rpc_password}" + +[splice] +enabled = true +poll_interval_secs = 1 "#, storage_dir = storage_dir.display(), ); diff --git a/tests/integration.rs b/tests/integration.rs index 49b7ad5..ec644dc 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1213,6 +1213,174 @@ async fn test_decodeoffer() { assert!(decoded["amountMsat"].is_null()); } +/// End-to-end auto-splice flow: pay → JIT channel #1 → close → on-chain funds +/// → pay again → JIT channel #2 → splice manager ticks → on-chain drained +/// into channel #2. +#[tokio::test(flavor = "multi_thread")] +async fn test_auto_splice_after_channel_close_and_reopen() { + let bitcoind = TestBitcoind::new(); + let lsp = LspNode::new(&bitcoind); + fund_lsp(&bitcoind, &lsp).await; + + let server = MdkdHandle::start(&bitcoind, None, Some(&lsp), &random_mnemonic()).await; + let payer = PayerNode::new(&bitcoind); + setup_payer_lsp_channel(&bitcoind, &payer, &lsp, 500_000).await; + + // First payment opens JIT channel #1. + let invoice: serde_json::Value = server + .post_form( + "/createinvoice", + &[ + ("amountSat", "100000"), + ("description", "splice setup"), + ("expirySeconds", "3600"), + ], + ) + .await + .json() + .await + .unwrap(); + let invoice_str = invoice["serialized"].as_str().unwrap(); + let payment_hash = invoice["paymentHash"].as_str().unwrap().to_string(); + payer.pay_invoice(invoice_str); + + let start = std::time::Instant::now(); + loop { + let resp: serde_json::Value = server + .get(&format!("/payments/incoming/{payment_hash}")) + .await + .json() + .await + .unwrap(); + if resp["isPaid"].as_bool().unwrap() { + break; + } + if start.elapsed() > Duration::from_secs(60) { + panic!("Timed out waiting for first payment to settle"); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // Close channel #1 cooperatively. + let channels: Vec = server.get("/listchannels").await.json().await.unwrap(); + assert_eq!(channels.len(), 1); + let channel_id = channels[0]["channelId"].as_str().unwrap().to_string(); + let resp = server + .post_form("/closechannel", &[("channelId", &channel_id)]) + .await; + assert_eq!(resp.status(), 200); + + let start = std::time::Instant::now(); + loop { + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(2)).await; + let channels: Vec = + server.get("/listchannels").await.json().await.unwrap(); + if channels.is_empty() { + break; + } + if start.elapsed() > Duration::from_secs(60) { + panic!("Timed out waiting for channel #1 to close"); + } + } + + // Wait for the close output to become spendable on the server side. + bitcoind.mine_blocks(6); + let start = std::time::Instant::now(); + let onchain_after_close = loop { + let balance: serde_json::Value = server.get("/getbalance").await.json().await.unwrap(); + let onchain = balance["onchainBalanceSat"].as_u64().unwrap(); + if onchain > 0 { + break onchain; + } + if start.elapsed() > Duration::from_secs(30) { + panic!("Timed out waiting for spendable on-chain balance: {balance}"); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(1)).await; + }; + assert!( + onchain_after_close > 50_000, + "Expected meaningful on-chain balance after close, got {onchain_after_close}" + ); + + // Second payment opens JIT channel #2 — the splice target. + let invoice: serde_json::Value = server + .post_form( + "/createinvoice", + &[ + ("amountSat", "100000"), + ("description", "splice trigger"), + ("expirySeconds", "3600"), + ], + ) + .await + .json() + .await + .unwrap(); + let invoice_str = invoice["serialized"].as_str().unwrap(); + let payment_hash = invoice["paymentHash"].as_str().unwrap().to_string(); + payer.pay_invoice(invoice_str); + + let start = std::time::Instant::now(); + loop { + let resp: serde_json::Value = server + .get(&format!("/payments/incoming/{payment_hash}")) + .await + .json() + .await + .unwrap(); + if resp["isPaid"].as_bool().unwrap() { + break; + } + if start.elapsed() > Duration::from_secs(60) { + panic!("Timed out waiting for second JIT payment to settle"); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // Splice manager polls every 1s (test config). Rather than racing a + // pre-splice capacity snapshot, assert conservation across two + // observations: 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. Mine blocks while waiting so the + // splice tx confirms and the LSP returns splice_locked (client- + // initiated splices are not 0-conf). + let splice_threshold = onchain_after_close / 10; + let start = std::time::Instant::now(); + let onchain_after_splice = loop { + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(1)).await; + let balance: serde_json::Value = server.get("/getbalance").await.json().await.unwrap(); + let onchain = balance["onchainBalanceSat"].as_u64().unwrap(); + if onchain < splice_threshold { + break onchain; + } + if start.elapsed() > Duration::from_secs(120) { + let channels: serde_json::Value = + server.get("/listchannels").await.json().await.unwrap(); + panic!( + "Auto-splice did not consume on-chain balance. \ + onchain_after_close={onchain_after_close}, balance={balance}, \ + channels={channels}" + ); + } + }; + + // Sanity: the channel absorbed at least the consumed on-chain funds. + let channels: Vec = server.get("/listchannels").await.json().await.unwrap(); + assert_eq!(channels.len(), 1); + let post_splice_capacity = channels[0]["capacitySat"].as_u64().unwrap(); + let consumed_onchain = onchain_after_close - onchain_after_splice; + assert!( + post_splice_capacity > consumed_onchain, + "Expected channel capacity to absorb the spliced funds. \ + capacity={post_splice_capacity}, consumed_onchain={consumed_onchain}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn test_decodeoffer_invalid() { let bitcoind = TestBitcoind::new();