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/index.d.ts b/index.d.ts index 7159235..1399fad 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 @@ -107,7 +119,12 @@ 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. + */ startReceiving(): void /** * Get the next payment event without ACKing it. @@ -120,7 +137,12 @@ 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. + * + * Tears down the splice manager before stopping the node so the loop + * never sees a stopped node mid-tick. + */ stopReceiving(): void syncWallets(): void getBalance(): number diff --git a/src/lib.rs b/src/lib.rs index 1f0a022..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,10 +52,14 @@ use ldk_node::{ payment::PaymentKind, }; use tokio::runtime::Runtime; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; #[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); @@ -296,6 +300,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)] +pub(crate) struct ResolvedSpliceConfig { + pub(crate) enabled: bool, + pub(crate) 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)] @@ -365,8 +407,21 @@ pub struct NodeChannel { #[napi] pub struct MdkNode { - node: Option, + node: Option>, network: Network, + /// Cached LSP pubkey. Used by the splice manager to filter eligible + /// channels by counterparty. + lsp_pubkey: PublicKey, + 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] @@ -470,9 +525,25 @@ 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); + + // 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), }) } @@ -481,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. @@ -492,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(); @@ -522,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| { @@ -533,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. @@ -645,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 new file mode 100644 index 0000000..5dda2c8 --- /dev/null +++ b/src/splice_manager.rs @@ -0,0 +1,505 @@ +//! 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`. `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; +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; + +/// 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); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SpliceError { + /// Channel exists but isn't usable right now (mid-splice, peer down, + /// mid-monitor-update). Retry next tick. + ChannelNotUsable, + /// Post-fee splice amount is below dust. Retry next tick. + InsufficientFunds, + /// Any other `NodeError` from `Node::splice_in`. + 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 each tracked splice. Drop entries whose channel disappeared; keep +/// entries whose `funding_txo` is unchanged (splice still negotiating); stamp +/// `promoted_at` when `funding_txo` flips and drop after `BDK_RESYNC_GRACE`. +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 over a tick's snapshot. +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); + } + + // 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) + .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, + }) +} + +/// 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, + 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) => { + // 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" + ); + 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 + } + } + } + } +} + +/// Pre-flight `is_usable` check, then delegate to ldk-node. +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) +} + +/// 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, + 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, + } + } +} + +/// In-flight entry. See [`advance_in_flight`] for the `promoted_at` semantics. +#[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() + } + + 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)); + } +}