From d57c1a865381ff3f3a0165596d4111cb4ea59ebf Mon Sep 17 00:00:00 2001 From: Vu Lam Date: Thu, 3 Jul 2025 21:48:06 +0700 Subject: [PATCH 1/9] added the ability to customize the gossip data --- examples/custom_gossip_example.rs | 154 +++++++++++++++ src/builder.rs | 193 +++++++++++++----- src/custom_gossip.rs | 319 ++++++++++++++++++++++++++++++ src/lib.rs | 27 +++ src/logger.rs | 5 +- src/message_handler.rs | 173 +++++++++++++++- 6 files changed, 821 insertions(+), 50 deletions(-) create mode 100644 examples/custom_gossip_example.rs create mode 100644 src/custom_gossip.rs diff --git a/examples/custom_gossip_example.rs b/examples/custom_gossip_example.rs new file mode 100644 index 0000000000..42fc96e271 --- /dev/null +++ b/examples/custom_gossip_example.rs @@ -0,0 +1,154 @@ +// Example demonstrating how to use custom gossip metadata in LDK Node + +use ldk_node::{Builder, Event, Node}; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::bitcoin::Network; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +fn main() -> Result<(), Box> { + // Create and configure a node with custom gossip enabled + let mut builder = Builder::new(); + builder.set_network(Network::Testnet); + builder.set_chain_source_esplora("https://blockstream.info/testnet/api".to_string(), None); + builder.set_gossip_source_rgs("https://rapidsync.lightningdevkit.org/testnet/snapshot".to_string()); + + // Enable custom gossip functionality + builder.enable_custom_gossip(); + + let node = builder.build()?; + + // Start the node + node.start()?; + + // Get the custom gossip handler + if let Some(custom_gossip) = node.custom_gossip() { + println!("Custom gossip handler is available!"); + + // Set our own metadata to advertise + let our_metadata = serde_json::json!({ + "version": "1.0", + "features": ["feature_a", "feature_b"], + "timestamp": chrono::Utc::now().timestamp(), + "description": "LDK Node with custom features" + }).to_string().into_bytes(); + + custom_gossip.set_our_metadata(our_metadata); + + // Example: Send custom metadata to a specific peer + // (This would typically be done after connecting to a peer) + let peer_metadata = serde_json::json!({ + "message": "Hello from custom gossip!", + "data": { + "custom_field": "custom_value" + } + }).to_string().into_bytes(); + + // In a real scenario, you'd have connected peers + // custom_gossip.send_metadata_to_peer(peer_node_id, peer_metadata); + + // Example: Get stored metadata for all nodes + let all_metadata = custom_gossip.get_all_metadata().clone(); + println!("Currently have metadata for {} nodes", all_metadata.len()); + + // Print metadata information + for (node_id, metadata) in all_metadata.clone() { + println!("Node {}: {} bytes of metadata", node_id, metadata.metadata.len()); + + // Try to parse as JSON + if let Ok(json_str) = String::from_utf8(metadata.metadata.clone()) { + if let Ok(json_value) = serde_json::from_str::(&json_str) { + println!(" Parsed JSON: {}", json_value); + } + } + } + + // Demonstrate event handling with custom gossip + println!("Monitoring for custom gossip events..."); + + // In a real application, you would handle events in a loop + // This is just a demonstration + for _ in 0..5 { + // Wait for events (timeout after 1 second) + std::thread::sleep(Duration::from_secs(1)); + + // In a real application, you would process events like this: + // match node.wait_next_event() { + // Event::... => { + // // Handle other events + // } + // // Custom gossip events would be handled through the custom_gossip handler + // // as they are processed automatically when messages are received + // } + } + + // Example: Check if we received any new metadata + let updated_metadata = custom_gossip.get_all_metadata(); + if updated_metadata.len() > all_metadata.clone().len() { + println!("Received new metadata from {} nodes", + updated_metadata.len() - all_metadata.len()); + } + + } else { + println!("Custom gossip not enabled. Use builder.enable_custom_gossip() to enable it."); + } + + // Stop the node + node.stop()?; + + peer_to_peer_example()?; + + Ok(()) +} + +/// Example of how to integrate custom gossip in a peer-to-peer scenario +#[allow(dead_code)] +fn peer_to_peer_example() -> Result<(), Box> { + // Create two nodes for demonstration + let mut builder1 = Builder::new(); + builder1.set_network(Network::Regtest); + builder1.enable_custom_gossip(); + let node1 = builder1.build()?; + + let mut builder2 = Builder::new(); + builder2.set_network(Network::Regtest); + builder2.enable_custom_gossip(); + let node2 = builder2.build()?; + + // Start both nodes + node1.start()?; + node2.start()?; + + // Get custom gossip handlers + let gossip1 = node1.custom_gossip().unwrap(); + let gossip2 = node2.custom_gossip().unwrap(); + + // Set metadata for each node + let metadata1 = b"Node 1 custom data".to_vec(); + let metadata2 = b"Node 2 custom data".to_vec(); + + gossip1.set_our_metadata(metadata1); + gossip2.set_our_metadata(metadata2); + + // In a real scenario, you would: + // 1. Connect the nodes to each other + // 2. Custom metadata would be automatically exchanged when peers connect + // 3. Monitor the get_all_metadata() results to see received data + + println!("Peer-to-peer custom gossip example completed"); + + // Stop nodes + node1.stop()?; + node2.stop()?; + + Ok(()) +} + +/// Example showing custom feature flags (placeholder for future implementation) +#[allow(dead_code)] +fn custom_features_example() { + println!("Custom feature flags would be implemented in the provided_node_features() method"); + println!("This allows advertising custom capabilities to peers during connection"); +} \ No newline at end of file diff --git a/src/builder.rs b/src/builder.rs index 31a0fee456..ad6c3fdc68 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -25,6 +25,7 @@ use crate::liquidity::{ LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder, }; use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger}; +use crate::custom_gossip::CustomGossipMessageHandler; use crate::message_handler::NodeCustomMessageHandler; use crate::peer_store::PeerStore; use crate::tx_broadcaster::TransactionBroadcaster; @@ -222,6 +223,7 @@ pub struct NodeBuilder { gossip_source_config: Option, liquidity_source_config: Option, log_writer_config: Option, + custom_gossip_enabled: bool, } impl NodeBuilder { @@ -238,6 +240,7 @@ impl NodeBuilder { let gossip_source_config = None; let liquidity_source_config = None; let log_writer_config = None; + let custom_gossip_enabled = false; Self { config, entropy_source_config, @@ -245,6 +248,7 @@ impl NodeBuilder { gossip_source_config, liquidity_source_config, log_writer_config, + custom_gossip_enabled, } } @@ -380,6 +384,18 @@ impl NodeBuilder { self } + /// Enables custom gossip message support for the [`Node`] instance. + /// + /// When enabled, the node will be able to send and receive custom gossip messages + /// containing metadata extensions to the standard Lightning gossip protocol. + /// + /// Custom gossip messages use message type 32769 and can contain arbitrary metadata + /// up to 4096 bytes in length. + pub fn enable_custom_gossip(&mut self) -> &mut Self { + self.custom_gossip_enabled = true; + self + } + /// Sets the used storage directory path. pub fn set_storage_dir_path(&mut self, storage_dir_path: String) -> &mut Self { self.config.storage_dir_path = storage_dir_path; @@ -609,6 +625,7 @@ impl NodeBuilder { self.chain_data_source_config.as_ref(), self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), + self.custom_gossip_enabled, seed_bytes, logger, Arc::new(vss_store), @@ -631,6 +648,7 @@ impl NodeBuilder { self.chain_data_source_config.as_ref(), self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), + self.custom_gossip_enabled, seed_bytes, logger, kv_store, @@ -779,6 +797,17 @@ impl ArcedNodeBuilder { self.inner.write().unwrap().set_liquidity_provider_lsps2(service_config); } + /// Enables custom gossip message support for the [`Node`] instance. + /// + /// When enabled, the node will be able to send and receive custom gossip messages + /// containing metadata extensions to the standard Lightning gossip protocol. + /// + /// Custom gossip messages use message type 32769 and can contain arbitrary metadata + /// up to 4096 bytes in length. + pub fn enable_custom_gossip(&self) { + self.inner.write().unwrap().enable_custom_gossip(); + } + /// Sets the used storage directory path. pub fn set_storage_dir_path(&self, storage_dir_path: String) { self.inner.write().unwrap().set_storage_dir_path(storage_dir_path); @@ -933,8 +962,8 @@ impl ArcedNodeBuilder { fn build_with_store_internal( config: Arc, chain_data_source_config: Option<&ChainDataSourceConfig>, gossip_source_config: Option<&GossipSourceConfig>, - liquidity_source_config: Option<&LiquiditySourceConfig>, seed_bytes: [u8; 64], - logger: Arc, kv_store: Arc, + liquidity_source_config: Option<&LiquiditySourceConfig>, custom_gossip_enabled: bool, + seed_bytes: [u8; 64], logger: Arc, kv_store: Arc, ) -> Result { if let Err(err) = may_announce_channel(&config) { if config.announcement_addresses.is_some() { @@ -1333,53 +1362,124 @@ fn build_with_store_internal( }, }; - let (liquidity_source, custom_message_handler) = - if let Some(lsc) = liquidity_source_config.as_ref() { - let mut liquidity_source_builder = LiquiditySourceBuilder::new( - Arc::clone(&wallet), - Arc::clone(&channel_manager), - Arc::clone(&keys_manager), - Arc::clone(&chain_source), - Arc::clone(&config), - Arc::clone(&logger), - ); - - lsc.lsps1_client.as_ref().map(|config| { - liquidity_source_builder.lsps1_client( - config.node_id, - config.address.clone(), - config.token.clone(), - ) - }); - - lsc.lsps2_client.as_ref().map(|config| { - liquidity_source_builder.lsps2_client( - config.node_id, - config.address.clone(), - config.token.clone(), - ) - }); + let (liquidity_source, custom_message_handler) = { + let has_liquidity = liquidity_source_config.is_some(); + let has_custom_gossip = custom_gossip_enabled; + + match (has_liquidity, has_custom_gossip) { + (true, true) => { + // Both liquidity and custom gossip enabled + let lsc = liquidity_source_config.as_ref().unwrap(); + let mut liquidity_source_builder = LiquiditySourceBuilder::new( + Arc::clone(&wallet), + Arc::clone(&channel_manager), + Arc::clone(&keys_manager), + Arc::clone(&chain_source), + Arc::clone(&config), + Arc::clone(&logger), + ); - let promise_secret = { - let lsps_xpriv = derive_xprv( + lsc.lsps1_client.as_ref().map(|config| { + liquidity_source_builder.lsps1_client( + config.node_id, + config.address.clone(), + config.token.clone(), + ) + }); + + lsc.lsps2_client.as_ref().map(|config| { + liquidity_source_builder.lsps2_client( + config.node_id, + config.address.clone(), + config.token.clone(), + ) + }); + + let promise_secret = { + let lsps_xpriv = derive_xprv( + Arc::clone(&config), + &seed_bytes, + LSPS_HARDENED_CHILD_INDEX, + Arc::clone(&logger), + )?; + lsps_xpriv.private_key.secret_bytes() + }; + lsc.lsps2_service.as_ref().map(|config| { + liquidity_source_builder.lsps2_service(promise_secret, config.clone()) + }); + + let liquidity_source = Arc::new(liquidity_source_builder.build()); + let gossip_handler = Arc::new(CustomGossipMessageHandler::new(Arc::clone(&logger))); + let custom_message_handler = Arc::new(NodeCustomMessageHandler::new_combined( + Arc::clone(&liquidity_source), + gossip_handler, + )); + (Some(liquidity_source), custom_message_handler) + }, + (true, false) => { + // Only liquidity enabled + let lsc = liquidity_source_config.as_ref().unwrap(); + let mut liquidity_source_builder = LiquiditySourceBuilder::new( + Arc::clone(&wallet), + Arc::clone(&channel_manager), + Arc::clone(&keys_manager), + Arc::clone(&chain_source), Arc::clone(&config), - &seed_bytes, - LSPS_HARDENED_CHILD_INDEX, Arc::clone(&logger), - )?; - lsps_xpriv.private_key.secret_bytes() - }; - lsc.lsps2_service.as_ref().map(|config| { - liquidity_source_builder.lsps2_service(promise_secret, config.clone()) - }); - - let liquidity_source = Arc::new(liquidity_source_builder.build()); - let custom_message_handler = - Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source))); - (Some(liquidity_source), custom_message_handler) - } else { - (None, Arc::new(NodeCustomMessageHandler::new_ignoring())) - }; + ); + + lsc.lsps1_client.as_ref().map(|config| { + liquidity_source_builder.lsps1_client( + config.node_id, + config.address.clone(), + config.token.clone(), + ) + }); + + lsc.lsps2_client.as_ref().map(|config| { + liquidity_source_builder.lsps2_client( + config.node_id, + config.address.clone(), + config.token.clone(), + ) + }); + + let promise_secret = { + let lsps_xpriv = derive_xprv( + Arc::clone(&config), + &seed_bytes, + LSPS_HARDENED_CHILD_INDEX, + Arc::clone(&logger), + )?; + lsps_xpriv.private_key.secret_bytes() + }; + lsc.lsps2_service.as_ref().map(|config| { + liquidity_source_builder.lsps2_service(promise_secret, config.clone()) + }); + + let liquidity_source = Arc::new(liquidity_source_builder.build()); + let custom_message_handler = Arc::new(NodeCustomMessageHandler::new_liquidity( + Arc::clone(&liquidity_source) + )); + (Some(liquidity_source), custom_message_handler) + }, + (false, true) => { + // Only custom gossip enabled + let gossip_handler = Arc::new(CustomGossipMessageHandler::new(Arc::clone(&logger))); + let custom_message_handler = Arc::new(NodeCustomMessageHandler::new_custom_gossip( + gossip_handler + )); + (None, custom_message_handler) + }, + (false, false) => { + // Neither enabled + (None, Arc::new(NodeCustomMessageHandler::new_ignoring())) + } + } + }; + + // Extract custom gossip handler for later use + let custom_gossip_handler = custom_message_handler.custom_gossip_handler(); let msg_handler = match gossip_source.as_gossip_sync() { GossipSync::P2P(p2p_gossip_sync) => MessageHandler { @@ -1514,6 +1614,7 @@ fn build_with_store_internal( network_graph, gossip_source, liquidity_source, + custom_gossip_handler, kv_store, logger, _router: router, diff --git a/src/custom_gossip.rs b/src/custom_gossip.rs new file mode 100644 index 0000000000..4eb39db890 --- /dev/null +++ b/src/custom_gossip.rs @@ -0,0 +1,319 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Custom gossip message handling for extending P2P gossip sync with custom metadata. + +use crate::logger::{log_debug, log_trace}; +use lightning::util::logger::Logger as LightningLogger; + +use lightning::io::{self, Read}; +use lightning::ln::msgs::LightningError; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::ln::wire::CustomMessageReader; +use lightning::ln::wire::Type; +use lightning::util::ser::{Readable, Writeable, Writer}; +use lightning_types::features::{InitFeatures, NodeFeatures}; + +use bitcoin::secp256k1::PublicKey; + +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex}; + +/// Custom message type for gossip metadata extensions +pub const CUSTOM_GOSSIP_MESSAGE_TYPE: u16 = 32769; // Odd number in custom range + +/// Custom gossip message containing metadata extensions +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomGossipMessage { + /// The metadata payload + pub metadata: Vec, +} + +impl CustomGossipMessage { + /// Create a new custom gossip message with the given metadata + pub fn new(metadata: Vec) -> Self { + Self { metadata } + } + + /// Get the metadata payload + pub fn metadata(&self) -> &[u8] { + &self.metadata + } +} + +impl Type for CustomGossipMessage { + fn type_id(&self) -> u16 { + CUSTOM_GOSSIP_MESSAGE_TYPE + } +} + +impl Writeable for CustomGossipMessage { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + // Write length prefix (u16) followed by the metadata + (self.metadata.len() as u16).write(writer)?; + writer.write_all(&self.metadata) + } +} + +impl Readable for CustomGossipMessage { + fn read(reader: &mut R) -> Result { + let length = ::read(reader)? as usize; + + // Limit metadata size to prevent DoS attacks + if length > 4096 { + return Err(lightning::ln::msgs::DecodeError::InvalidValue); + } + + let mut metadata = vec![0u8; length]; + reader.read_exact(&mut metadata).map_err(|_| { + lightning::ln::msgs::DecodeError::ShortRead + })?; + + Ok(Self { metadata }) + } +} + +/// Metadata entry for a node +#[derive(Clone, Debug)] +pub struct NodeMetadata { + /// Node's public key + pub node_id: PublicKey, + /// Custom metadata payload + pub metadata: Vec, + /// Timestamp when metadata was received + pub timestamp: u32, +} + +/// Handler for custom gossip messages +pub struct CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + /// Logger instance + logger: L, + /// Store for node metadata + node_metadata: Arc>>, + /// Pending messages to send + pending_messages: Arc>>, + /// Our own metadata to advertise + our_metadata: Arc>>>, +} + +impl CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + /// Create a new custom gossip message handler + pub fn new(logger: L) -> Self { + Self { + logger, + node_metadata: Arc::new(Mutex::new(HashMap::new())), + pending_messages: Arc::new(Mutex::new(Vec::new())), + our_metadata: Arc::new(Mutex::new(None)), + } + } + + /// Set our own metadata to advertise to peers + pub fn set_our_metadata(&self, metadata: Vec) { + let mut our_metadata = self.our_metadata.lock().unwrap(); + *our_metadata = Some(metadata); + } + + /// Get metadata for a specific node + pub fn get_node_metadata(&self, node_id: &PublicKey) -> Option { + let metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.get(node_id).cloned() + } + + /// Get all stored node metadata + pub fn get_all_metadata(&self) -> HashMap { + let metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.clone() + } + + /// Send custom metadata to a specific peer + pub fn send_metadata_to_peer(&self, peer_node_id: PublicKey, metadata: Vec) { + let message = CustomGossipMessage::new(metadata); + let mut pending = self.pending_messages.lock().unwrap(); + pending.push((peer_node_id, message)); + } + + /// Broadcast our metadata to all peers + pub fn broadcast_our_metadata(&self, peer_node_ids: Vec) { + let our_metadata = self.our_metadata.lock().unwrap(); + if let Some(ref metadata) = *our_metadata { + let message = CustomGossipMessage::new(metadata.clone()); + let mut pending = self.pending_messages.lock().unwrap(); + + for node_id in peer_node_ids { + pending.push((node_id, message.clone())); + } + } + } + + /// Handle received custom gossip message + fn handle_gossip_message(&self, msg: &CustomGossipMessage, sender_node_id: PublicKey) { + log_debug!( + self.logger, + "Received custom gossip metadata from {}: {} bytes", + sender_node_id, + msg.metadata.len() + ); + + let metadata_entry = NodeMetadata { + node_id: sender_node_id, + metadata: msg.metadata.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32, + }; + + let mut metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.insert(sender_node_id, metadata_entry); + + log_trace!( + self.logger, + "Stored metadata for node {}, total nodes: {}", + sender_node_id, + metadata_store.len() + ); + } +} + +impl CustomMessageReader for CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + type CustomMessage = CustomGossipMessage; + + fn read( + &self, message_type: u16, buffer: &mut RD, + ) -> Result, lightning::ln::msgs::DecodeError> { + if message_type == CUSTOM_GOSSIP_MESSAGE_TYPE { + log_trace!(self.logger, "Reading custom gossip message type {}", message_type); + Ok(Some(CustomGossipMessage::read(buffer)?)) + } else { + Ok(None) + } + } +} + +impl CustomMessageHandler for CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + fn handle_custom_message( + &self, msg: Self::CustomMessage, sender_node_id: PublicKey, + ) -> Result<(), LightningError> { + self.handle_gossip_message(&msg, sender_node_id); + Ok(()) + } + + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { + let mut pending = self.pending_messages.lock().unwrap(); + std::mem::take(&mut *pending) + } + + fn provided_node_features(&self) -> NodeFeatures { + // Advertise that we support custom gossip messages + // You can extend this to include specific feature flags + NodeFeatures::empty() + } + + fn provided_init_features(&self, _their_node_id: PublicKey) -> InitFeatures { + // Advertise init features for custom gossip support + InitFeatures::empty() + } + + fn peer_connected( + &self, their_node_id: PublicKey, _msg: &lightning::ln::msgs::Init, _inbound: bool, + ) -> Result<(), ()> { + log_debug!(self.logger, "Peer {} connected, will broadcast our metadata", their_node_id); + + // Optionally broadcast our metadata when a peer connects + let our_metadata = self.our_metadata.lock().unwrap(); + if let Some(ref metadata) = *our_metadata { + let message = CustomGossipMessage::new(metadata.clone()); + let mut pending = self.pending_messages.lock().unwrap(); + pending.push((their_node_id, message)); + } + + Ok(()) + } + + fn peer_disconnected(&self, their_node_id: PublicKey) { + log_debug!(self.logger, "Peer {} disconnected", their_node_id); + // Optionally clean up metadata for disconnected peers + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::logger::test_logger; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning::util::ser::{Readable, Writeable}; + use std::io::Cursor; + + #[test] + fn test_custom_gossip_message_serialization() { + let metadata = b"custom_metadata_payload".to_vec(); + let msg = CustomGossipMessage::new(metadata.clone()); + + assert_eq!(msg.metadata(), &metadata); + assert_eq!(msg.type_id(), CUSTOM_GOSSIP_MESSAGE_TYPE); + + // Test serialization + let mut buffer = Vec::new(); + msg.write(&mut buffer).unwrap(); + + // Test deserialization + let mut cursor = Cursor::new(buffer); + let deserialized = CustomGossipMessage::read(&mut cursor).unwrap(); + + assert_eq!(msg, deserialized); + } + + #[test] + fn test_custom_gossip_handler() { + let logger = test_logger(); + let handler = CustomGossipMessageHandler::new(logger); + + // Test setting our metadata + let our_metadata = b"our_node_metadata".to_vec(); + handler.set_our_metadata(our_metadata.clone()); + + // Test handling a message + let secp_ctx = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[1; 32]).unwrap(); + let sender_node_id = PublicKey::from_secret_key(&secp_ctx, &secret_key); + + let msg = CustomGossipMessage::new(b"peer_metadata".to_vec()); + handler.handle_custom_message(msg, sender_node_id).unwrap(); + + // Verify metadata was stored + let stored_metadata = handler.get_node_metadata(&sender_node_id).unwrap(); + assert_eq!(stored_metadata.metadata, b"peer_metadata"); + assert_eq!(stored_metadata.node_id, sender_node_id); + } + + #[test] + fn test_message_size_limit() { + let large_metadata = vec![0u8; 5000]; // Exceeds 4096 byte limit + let msg = CustomGossipMessage::new(large_metadata); + + let mut buffer = Vec::new(); + msg.write(&mut buffer).unwrap(); + + let mut cursor = Cursor::new(buffer); + let result = CustomGossipMessage::read(&mut cursor); + + assert!(result.is_err()); + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index b09f9a9f77..134acbf346 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,6 +80,7 @@ mod builder; mod chain; pub mod config; mod connection; +pub mod custom_gossip; mod data_store; mod error; mod event; @@ -130,6 +131,7 @@ use config::{ PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL, }; use connection::ConnectionManager; +use custom_gossip::CustomGossipMessageHandler; use event::{EventHandler, EventQueue}; use gossip::GossipSource; use graph::NetworkGraph; @@ -195,6 +197,7 @@ pub struct Node { network_graph: Arc, gossip_source: Arc, liquidity_source: Option>>>, + custom_gossip_handler: Option>>>, kv_store: Arc, logger: Arc, _router: Arc, @@ -974,6 +977,30 @@ impl Node { )) } + /// Returns a custom gossip handler allowing to send and receive custom gossip messages. + /// + /// This returns `None` if custom gossip was not enabled during node construction. + /// To enable custom gossip, call [`Builder::enable_custom_gossip`] before building the node. + /// + /// Custom gossip messages can contain arbitrary metadata up to 4096 bytes in length + /// and use message type 32769 to extend the Lightning gossip protocol. + #[cfg(not(feature = "uniffi"))] + pub fn custom_gossip(&self) -> Option<&CustomGossipMessageHandler>> { + self.custom_gossip_handler.as_ref().map(|h| h.as_ref()) + } + + /// Returns a custom gossip handler allowing to send and receive custom gossip messages. + /// + /// This returns `None` if custom gossip was not enabled during node construction. + /// To enable custom gossip, call [`Builder::enable_custom_gossip`] before building the node. + /// + /// Custom gossip messages can contain arbitrary metadata up to 4096 bytes in length + /// and use message type 32769 to extend the Lightning gossip protocol. + #[cfg(feature = "uniffi")] + pub fn custom_gossip(&self) -> Option>>> { + self.custom_gossip_handler.clone() + } + /// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. /// /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md diff --git a/src/logger.rs b/src/logger.rs index bbd24ec207..11c82a8454 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -174,7 +174,8 @@ impl LogWriter for Writer { } } -pub(crate) struct Logger { +/// A logger for LDK Node that can write to files, the log facade, or custom writers. +pub struct Logger { /// Specifies the logger's writer. writer: Writer, } @@ -198,10 +199,12 @@ impl Logger { Ok(Self { writer: Writer::FileWriter { file_path, max_log_level } }) } + /// Creates a new logger that forwards logs to the `log` facade. pub fn new_log_facade() -> Self { Self { writer: Writer::LogFacadeWriter } } + /// Creates a new logger with a custom writer. pub fn new_custom_writer(log_writer: Arc) -> Self { Self { writer: Writer::CustomWriter(log_writer) } } diff --git a/src/message_handler.rs b/src/message_handler.rs index cebd1ea07c..cf7e486638 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -5,6 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +use crate::custom_gossip::{CustomGossipMessage, CustomGossipMessageHandler}; use crate::liquidity::LiquiditySource; use lightning::ln::peer_handler::CustomMessageHandler; @@ -26,6 +27,11 @@ where { Ignoring, Liquidity { liquidity_source: Arc> }, + CustomGossip { gossip_handler: Arc> }, + Combined { + liquidity_source: Arc>, + gossip_handler: Arc>, + }, } impl NodeCustomMessageHandler @@ -39,13 +45,58 @@ where pub(crate) fn new_ignoring() -> Self { Self::Ignoring } + + pub(crate) fn new_custom_gossip(gossip_handler: Arc>) -> Self { + Self::CustomGossip { gossip_handler } + } + + pub(crate) fn new_combined( + liquidity_source: Arc>, + gossip_handler: Arc>, + ) -> Self { + Self::Combined { liquidity_source, gossip_handler } + } + + /// Returns the custom gossip handler if available + pub(crate) fn custom_gossip_handler(&self) -> Option>> { + match self { + Self::CustomGossip { gossip_handler } => Some(Arc::clone(gossip_handler)), + Self::Combined { gossip_handler, .. } => Some(Arc::clone(gossip_handler)), + _ => None, + } + } +} + +/// Combined custom message type that can handle both LSPS and custom gossip messages +#[derive(Clone, Debug)] +pub(crate) enum NodeCustomMessage { + Lsps(RawLSPSMessage), + CustomGossip(CustomGossipMessage), +} + +impl lightning::ln::wire::Type for NodeCustomMessage { + fn type_id(&self) -> u16 { + match self { + Self::Lsps(msg) => msg.type_id(), + Self::CustomGossip(msg) => msg.type_id(), + } + } +} + +impl lightning::util::ser::Writeable for NodeCustomMessage { + fn write(&self, writer: &mut W) -> Result<(), lightning::io::Error> { + match self { + Self::Lsps(msg) => msg.write(writer), + Self::CustomGossip(msg) => msg.write(writer), + } + } } impl CustomMessageReader for NodeCustomMessageHandler where L::Target: Logger, { - type CustomMessage = RawLSPSMessage; + type CustomMessage = NodeCustomMessage; fn read( &self, message_type: u16, buffer: &mut RD, @@ -53,7 +104,28 @@ where match self { Self::Ignoring => Ok(None), Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().read(message_type, buffer) + if let Ok(Some(lsps_msg)) = liquidity_source.liquidity_manager().read(message_type, buffer) { + Ok(Some(NodeCustomMessage::Lsps(lsps_msg))) + } else { + Ok(None) + } + }, + Self::CustomGossip { gossip_handler, .. } => { + if let Ok(Some(gossip_msg)) = gossip_handler.read(message_type, buffer) { + Ok(Some(NodeCustomMessage::CustomGossip(gossip_msg))) + } else { + Ok(None) + } + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Try LSPS first, then custom gossip + if let Ok(Some(lsps_msg)) = liquidity_source.liquidity_manager().read(message_type, buffer) { + Ok(Some(NodeCustomMessage::Lsps(lsps_msg))) + } else if let Ok(Some(gossip_msg)) = gossip_handler.read(message_type, buffer) { + Ok(Some(NodeCustomMessage::CustomGossip(gossip_msg))) + } else { + Ok(None) + } }, } } @@ -69,7 +141,36 @@ where match self { Self::Ignoring => Ok(()), // Should be unreachable!() as the reader will return `None` Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().handle_custom_message(msg, sender_node_id) + match msg { + NodeCustomMessage::Lsps(lsps_msg) => { + liquidity_source.liquidity_manager().handle_custom_message(lsps_msg, sender_node_id) + }, + NodeCustomMessage::CustomGossip(_) => { + // Ignoring custom gossip in liquidity-only mode + Ok(()) + }, + } + }, + Self::CustomGossip { gossip_handler, .. } => { + match msg { + NodeCustomMessage::CustomGossip(gossip_msg) => { + gossip_handler.handle_custom_message(gossip_msg, sender_node_id) + }, + NodeCustomMessage::Lsps(_) => { + // Ignoring LSPS in gossip-only mode + Ok(()) + }, + } + }, + Self::Combined { liquidity_source, gossip_handler } => { + match msg { + NodeCustomMessage::Lsps(lsps_msg) => { + liquidity_source.liquidity_manager().handle_custom_message(lsps_msg, sender_node_id) + }, + NodeCustomMessage::CustomGossip(gossip_msg) => { + gossip_handler.handle_custom_message(gossip_msg, sender_node_id) + }, + } }, } } @@ -79,6 +180,34 @@ where Self::Ignoring => Vec::new(), Self::Liquidity { liquidity_source, .. } => { liquidity_source.liquidity_manager().get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::Lsps(msg))) + .collect() + }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::CustomGossip(msg))) + .collect() + }, + Self::Combined { liquidity_source, gossip_handler } => { + let mut pending = Vec::new(); + + // Get LSPS messages + pending.extend( + liquidity_source.liquidity_manager().get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::Lsps(msg))) + ); + + // Get custom gossip messages + pending.extend( + gossip_handler.get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::CustomGossip(msg))) + ); + + pending }, } } @@ -89,6 +218,17 @@ where Self::Liquidity { liquidity_source, .. } => { liquidity_source.liquidity_manager().provided_node_features() }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.provided_node_features() + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Combine features from both handlers + let features = liquidity_source.liquidity_manager().provided_node_features(); + let _gossip_features = gossip_handler.provided_node_features(); + // Note: In a real implementation, you'd need to properly merge features + // For now, we'll use the liquidity features as base + features + }, } } @@ -98,6 +238,17 @@ where Self::Liquidity { liquidity_source, .. } => { liquidity_source.liquidity_manager().provided_init_features(their_node_id) }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.provided_init_features(their_node_id) + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Combine init features from both handlers + let features = liquidity_source.liquidity_manager().provided_init_features(their_node_id); + let _gossip_features = gossip_handler.provided_init_features(their_node_id); + // Note: In a real implementation, you'd need to properly merge features + // For now, we'll use the liquidity features as base + features + }, } } @@ -109,6 +260,14 @@ where Self::Liquidity { liquidity_source, .. } => { liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound) }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.peer_connected(their_node_id, msg, inbound) + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Notify both handlers + let _ = liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound); + gossip_handler.peer_connected(their_node_id, msg, inbound) + }, } } @@ -118,6 +277,14 @@ where Self::Liquidity { liquidity_source, .. } => { liquidity_source.liquidity_manager().peer_disconnected(their_node_id) }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.peer_disconnected(their_node_id) + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Notify both handlers + liquidity_source.liquidity_manager().peer_disconnected(their_node_id); + gossip_handler.peer_disconnected(their_node_id); + }, } } } From 000a3309539a86d953d2ffe6b8fc2b7f62a4abae Mon Sep 17 00:00:00 2001 From: Vu Lam Date: Thu, 25 Sep 2025 04:10:48 +0700 Subject: [PATCH 2/9] added sync events action --- src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index a5b6c7652d..3691cd34f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -833,6 +833,11 @@ impl Node { self.config.node_alias } + pub fn process_events(&self) -> Arc { + self.peer_manager.process_events(); + Arc::clone(&self.peer_manager) + } + /// Returns a payment handler allowing to create and pay [BOLT 11] invoices. /// /// [BOLT 11]: https://github.com/lightning/bolts/blob/master/11-payment-encoding.md From 8e1599b378e8a834e5bdd649c8f509c4e8637e2d Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Wed, 29 Apr 2026 15:07:27 +0700 Subject: [PATCH 3/9] fix: improve to suitable with onboarding flow --- src/config.rs | 9 +++ src/lib.rs | 72 +++++++++++--------- src/payment/bolt11.rs | 152 +++++++++++++++++++++++++++++++++++++++++- src/tx_broadcaster.rs | 10 ++- 4 files changed, 209 insertions(+), 34 deletions(-) diff --git a/src/config.rs b/src/config.rs index a2930ea5a9..5d9dd59c3c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -308,6 +308,15 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { user_config.manually_accept_inbound_channels = true; user_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = config.anchor_channels_config.is_some(); + // Allow full-capacity HTLCs. LDK's + // `max_inbound_htlc_value_in_flight_percent_of_channel` defaults to 10%, + // which rejects `temporary_channel_failure` on any forward larger than + // a tenth of the channel. Onboarding sweep needs to push ~70% of a + // single-channel capacity through its LSP in one HTLC, so we raise the + // cap to 100% for every channel this node negotiates (both as opener + // and as accepting peer). This matches the override already applied + // when we are an LSPS2 client/service (see `Builder::build_with_store`). + user_config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; if may_announce_channel(config).is_err() { user_config.accept_forwards_to_priv_channels = false; diff --git a/src/lib.rs b/src/lib.rs index 3691cd34f9..7809cdb4fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -833,6 +833,7 @@ impl Node { self.config.node_alias } + /// Processes pending peer manager events and returns a handle to the peer manager. pub fn process_events(&self) -> Arc { self.peer_manager.process_events(); Arc::clone(&self.peer_manager) @@ -847,6 +848,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), + Arc::clone(&self.keys_manager), self.liquidity_source.clone(), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), @@ -864,6 +866,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), + Arc::clone(&self.keys_manager), self.liquidity_source.clone(), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), @@ -1282,43 +1285,52 @@ impl Node { /// /// [`EsploraSyncConfig::background_sync_config`]: crate::config::EsploraSyncConfig::background_sync_config pub fn sync_wallets(&self) -> Result<(), Error> { + // PATCH (econ-v1 / sweep-flow E2E): this previously constructed a brand-new + // `tokio::runtime::Builder::new_multi_thread()` inside `block_in_place`. + // When `sync_wallets` is called from a non-tokio host thread (e.g. the + // cdylib FFI handler thread that our node-app builtin dispatch uses), the + // `block_in_place` panics because that API requires a tokio runtime + // context on the current thread — and the panic is silently swallowed + // since the cdylib boundary eats non-unwinding panics. The result is + // that `sync_wallets` hangs forever from the caller's perspective. + // + // Fix: reuse the node's already-initialized runtime (which is what the + // `start()` method at the top of this file does for its own sync call). + // `runtime.block_on(...)` is valid from any thread, with or without a + // surrounding tokio context, and it drives the chain-sync futures on + // the runtime's existing worker pool instead of constructing a parallel + // one per call. let rt_lock = self.runtime.read().unwrap(); - if rt_lock.is_none() { - return Err(Error::NotRunning); - } + let runtime = rt_lock.as_ref().ok_or(Error::NotRunning)?; let chain_source = Arc::clone(&self.chain_source); let sync_cman = Arc::clone(&self.channel_manager); let sync_cmon = Arc::clone(&self.chain_monitor); let sync_sweeper = Arc::clone(&self.output_sweeper); - tokio::task::block_in_place(move || { - tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on( - async move { - match chain_source.as_ref() { - ChainSource::Esplora { .. } => { - chain_source.update_fee_rate_estimates().await?; - chain_source - .sync_lightning_wallet(sync_cman, sync_cmon, sync_sweeper) - .await?; - chain_source.sync_onchain_wallet().await?; - }, - ChainSource::Electrum { .. } => { - chain_source.update_fee_rate_estimates().await?; - chain_source - .sync_lightning_wallet(sync_cman, sync_cmon, sync_sweeper) - .await?; - chain_source.sync_onchain_wallet().await?; - }, - ChainSource::Bitcoind { .. } => { - chain_source.update_fee_rate_estimates().await?; - chain_source - .poll_and_update_listeners(sync_cman, sync_cmon, sync_sweeper) - .await?; - }, - } - Ok(()) + runtime.block_on(async move { + match chain_source.as_ref() { + ChainSource::Esplora { .. } => { + chain_source.update_fee_rate_estimates().await?; + chain_source + .sync_lightning_wallet(sync_cman, sync_cmon, sync_sweeper) + .await?; + chain_source.sync_onchain_wallet().await?; }, - ) + ChainSource::Electrum { .. } => { + chain_source.update_fee_rate_estimates().await?; + chain_source + .sync_lightning_wallet(sync_cman, sync_cmon, sync_sweeper) + .await?; + chain_source.sync_onchain_wallet().await?; + }, + ChainSource::Bitcoind { .. } => { + chain_source.update_fee_rate_estimates().await?; + chain_source + .poll_and_update_listeners(sync_cman, sync_cmon, sync_sweeper) + .await?; + }, + } + Ok(()) }) } diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 817a428bdf..7b90c91759 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -22,23 +22,27 @@ use crate::payment::store::{ }; use crate::payment::SendingParameters; use crate::peer_store::{PeerInfo, PeerStore}; -use crate::types::{ChannelManager, PaymentStore}; +use crate::types::{ChannelManager, KeysManager, PaymentStore}; use lightning::ln::bolt11_payment; use lightning::ln::channelmanager::{ Bolt11InvoiceParameters, PaymentId, RecipientOnionFields, Retry, RetryableSendFailure, + MIN_FINAL_CLTV_EXPIRY_DELTA, }; -use lightning::routing::router::{PaymentParameters, RouteParameters}; +use lightning::routing::router::{PaymentParameters, RouteHint, RouteParameters}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; use lightning_invoice::Bolt11Invoice as LdkBolt11Invoice; use lightning_invoice::Bolt11InvoiceDescription as LdkBolt11InvoiceDescription; +use lightning_invoice::InvoiceBuilder; -use bitcoin::hashes::sha256::Hash as Sha256; +use bitcoin::hashes::sha256::{self, Hash as Sha256}; use bitcoin::hashes::Hash; +use bitcoin::secp256k1::Secp256k1; use std::sync::{Arc, RwLock}; +use std::time::Duration; #[cfg(not(feature = "uniffi"))] type Bolt11Invoice = LdkBolt11Invoice; @@ -60,6 +64,7 @@ pub struct Bolt11Payment { runtime: Arc>>>, channel_manager: Arc, connection_manager: Arc>>, + keys_manager: Arc, liquidity_source: Option>>>, payment_store: Arc, peer_store: Arc>>, @@ -72,6 +77,7 @@ impl Bolt11Payment { runtime: Arc>>>, channel_manager: Arc, connection_manager: Arc>>, + keys_manager: Arc, liquidity_source: Option>>>, payment_store: Arc, peer_store: Arc>>, config: Arc, logger: Arc, @@ -80,6 +86,7 @@ impl Bolt11Payment { runtime, channel_manager, connection_manager, + keys_manager, liquidity_source, payment_store, peer_store, @@ -499,6 +506,145 @@ impl Bolt11Payment { Ok(maybe_wrap(invoice)) } + /// Returns a payable invoice whose route hints are supplied by the caller, bypassing + /// `ChannelManager::create_bolt11_invoice`'s filter that drops any inbound channel whose + /// counterparty has not yet sent a `channel_update` (i.e. `forwarding_info = None`). + /// + /// This is required for topologies where a leaf LSP keeps the channel private and never + /// issues a unicast `channel_update`, making the default invoice builder return an empty + /// route-hint list. The caller is responsible for building hints that match the peer's + /// actual forwarding policy. + pub fn receive_with_hints( + &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, + route_hints: Vec, + ) -> Result { + let description = maybe_try_convert_enum(description)?; + let invoice = + self.receive_with_hints_inner(Some(amount_msat), &description, expiry_secs, None, route_hints)?; + Ok(maybe_wrap(invoice)) + } + + /// HODL variant of [`receive_with_hints`] — registers a caller-supplied `payment_hash` + /// so the caller can later release the preimage via [`claim_for_hash`]. + /// + /// [`claim_for_hash`]: Self::claim_for_hash + pub fn receive_for_hash_with_hints( + &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: PaymentHash, route_hints: Vec, + ) -> Result { + let description = maybe_try_convert_enum(description)?; + let invoice = self.receive_with_hints_inner( + Some(amount_msat), + &description, + expiry_secs, + Some(payment_hash), + route_hints, + )?; + Ok(maybe_wrap(invoice)) + } + + pub(crate) fn receive_with_hints_inner( + &self, amount_msat: Option, invoice_description: &LdkBolt11InvoiceDescription, + expiry_secs: u32, manual_claim_payment_hash: Option, + route_hints: Vec, + ) -> Result { + let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA; + + let (payment_hash_ldk, payment_secret) = if let Some(manual_hash) = + manual_claim_payment_hash + { + let secret = self + .channel_manager + .create_inbound_payment_for_hash( + manual_hash, + amount_msat, + expiry_secs, + Some(min_final_cltv_expiry_delta), + ) + .map_err(|e| { + log_error!( + self.logger, + "Failed to register inbound payment for hash: {:?}", + e + ); + Error::InvoiceCreationFailed + })?; + (manual_hash, secret) + } else { + self.channel_manager + .create_inbound_payment( + amount_msat, + expiry_secs, + Some(min_final_cltv_expiry_delta), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment: {:?}", e); + Error::InvoiceCreationFailed + })? + }; + + let payment_hash_sha = sha256::Hash::from_slice(&payment_hash_ldk.0).map_err(|e| { + log_error!(self.logger, "Invalid payment hash: {:?}", e); + Error::InvoiceCreationFailed + })?; + + let currency = self.config.network.into(); + let mut invoice_builder = InvoiceBuilder::new(currency) + .invoice_description(invoice_description.clone()) + .payment_hash(payment_hash_sha) + .payment_secret(payment_secret) + .current_timestamp() + .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) + .expiry_time(Duration::from_secs(expiry_secs.into())); + + for hint in route_hints { + invoice_builder = invoice_builder.private_route(hint); + } + + if let Some(amount_msat) = amount_msat { + invoice_builder = + invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); + } + + let invoice = invoice_builder + .build_signed(|hash| { + Secp256k1::new() + .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) + }) + .map_err(|e| { + log_error!(self.logger, "Failed to build and sign invoice: {}", e); + Error::InvoiceCreationFailed + })?; + + log_info!(self.logger, "Invoice (with manual route hints) created: {}", invoice); + + let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); + let id = PaymentId(payment_hash.0); + let preimage = if manual_claim_payment_hash.is_none() { + self.channel_manager + .get_payment_preimage(payment_hash, invoice.payment_secret().clone()) + .ok() + } else { + None + }; + let kind = PaymentKind::Bolt11 { + hash: payment_hash, + preimage, + secret: Some(invoice.payment_secret().clone()), + }; + let payment = PaymentDetails::new( + id, + kind, + amount_msat, + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + self.payment_store.insert(payment)?; + + Ok(invoice) + } + pub(crate) fn receive_inner( &self, amount_msat: Option, invoice_description: &LdkBolt11InvoiceDescription, expiry_secs: u32, manual_claim_payment_hash: Option, diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 09189b1371..15b135de48 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -16,7 +16,15 @@ use tokio::sync::{Mutex, MutexGuard}; use std::ops::Deref; -const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +// Bumped from 50 to 500 because LDK's onchain claim-bump logic floods the +// queue with rebroadcasts of stuck force-close commitment TXs (each new +// block triggers another retry). Once the queue fills up, new broadcasts — +// including one-shot sweep/funding TXs the onboarding flow depends on — +// are silently dropped with `try_send` returning `Full`. 500 is generous +// enough that legitimate sweep/funding broadcasts always make it through +// even when an old monitor's commitment-tx is stuck looping against +// bitcoind's "Transaction outputs already in utxo set" rejection. +const BCAST_PACKAGE_QUEUE_SIZE: usize = 500; pub(crate) struct TransactionBroadcaster where From a6e82313352251e222c9256336f92cc129097fda Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Fri, 8 May 2026 23:59:21 +0700 Subject: [PATCH 4/9] fix: opt in to forwarding HTLCs over private channelsupport paris version --- src/config.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 5d9dd59c3c..7bf91f34be 100644 --- a/src/config.rs +++ b/src/config.rs @@ -317,9 +317,23 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { // and as accepting peer). This matches the override already applied // when we are an LSPS2 client/service (see `Builder::build_with_store`). user_config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + // Permit forwarding HTLCs over private channels regardless of whether + // this node has a publicly-announceable identity. Onboarding's HODL-peer + // flow relies on a private last hop (Alice→Bob inbound channel kept + // private so the buyer's invoice carries a route hint). Without this, + // ChannelManager's `can_forward_htlc_to_outgoing_channel` short-circuits + // with `unknown_next_peer (0x400a)` whenever an HTLC is targeted at a + // private channel — this is by design in upstream LDK to hide + // private-channel existence from forwarders, but it breaks any + // leaf-LSP topology that relies on private channels as forwardable + // hops. The two settings (forwarding-over-private-channels vs + // announcing-our-own-channels) are conceptually independent, so we + // leave this enabled even when `may_announce_channel` reports the + // node is missing alias/addresses; only the gossip-related toggles + // are gated on announceability. + user_config.accept_forwards_to_priv_channels = true; if may_announce_channel(config).is_err() { - user_config.accept_forwards_to_priv_channels = false; user_config.channel_handshake_config.announce_for_forwarding = false; user_config.channel_handshake_limits.force_announced_channel_preference = true; } From 5bad3445bd8b4384dec33f557914f0d6da55072f Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Fri, 5 Jun 2026 08:56:41 +0700 Subject: [PATCH 5/9] fix(fee): target 3 blocks for channel funding txs so they confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ChannelFunding confirmation target resolved to a 12-block fee tier, which during normal mempool congestion maps to a rate low enough that funding transactions can sit unconfirmed for hours. The channel never reaches channel_ready and the UI is stuck on `sync`. Lower the target to ~3 blocks (mempool's "fast" tier) so funding txs are mined promptly. The fee is still sourced from the chain source's recommended estimates (esplora get_fee_estimates / electrum estimatefee / bitcoind estimatesmartfee) — only the targeted confirmation window changes, and only for funding transactions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fee_estimator.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/fee_estimator.rs b/src/fee_estimator.rs index f000245aa9..da7833d835 100644 --- a/src/fee_estimator.rs +++ b/src/fee_estimator.rs @@ -87,7 +87,11 @@ impl LdkFeeEstimator for OnchainFeeEstimator { pub(crate) fn get_num_block_defaults_for_target(target: ConfirmationTarget) -> usize { match target { ConfirmationTarget::OnchainPayment => 6, - ConfirmationTarget::ChannelFunding => 12, + // Funding txs target ~3 blocks (mempool's "fast" tier) so they confirm + // promptly. The prior 12-block target resolved to a fee low enough that + // funding txs could sit unconfirmed for hours during normal congestion, + // stalling channels in `sync` (never reaching `channel_ready`). + ConfirmationTarget::ChannelFunding => 3, ConfirmationTarget::Lightning(ldk_target) => match ldk_target { LdkConfirmationTarget::MaximumFeeEstimate => 1, LdkConfirmationTarget::UrgentOnChainSweep => 6, From 0cb3f338c58bbb661f307189c64080c99f1037e1 Mon Sep 17 00:00:00 2001 From: tonible14012002 Date: Tue, 30 Jun 2026 19:03:43 +0700 Subject: [PATCH 6/9] fix(chain/bitcoind): swap_tx_confirmations queried txid in reversed byte order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getrawtransaction wants the txid in RPC/display (big-endian) order (Txid Display), but serialize_hex(txid) emits internal little-endian (reversed) bytes → bitcoind returns -5, mapped to Ok(None)=NotFound. The native swap watcher therefore never saw a confirmed opening tx on the bitcoind backend, so the CSV/confirmation ladder never armed and swaps wedged at AWAIT_CONFIRM/AWAIT_CLAIM_PAYMENT. Fix: pass txid.to_string() (display order). Proven on regtest (display txid=10 confs, reversed=-5). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chain/bitcoind.rs | 77 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 98e77cac7c..33b2947017 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -296,6 +296,68 @@ impl BitcoindClient { } } + /// Confirmation depth for an ARBITRARY `txid` via verbose `getrawtransaction` + /// (Peerswap native primitive B5). + /// + /// To locate a tx by its id alone (the swap case — a counterparty opening tx + /// that is not in our wallet), the backend must be able to find it, i.e. a + /// `-txindex` node (or the tx still resident in the mempool). Returns: + /// - `Ok(Some(n))` with `n >= 1` for a tx confirmed `n` blocks deep, + /// - `Ok(Some(0))` for a tx seen in the mempool but unconfirmed, + /// - `Ok(None)` when the node does not know the tx (RPC error code -5), + /// - `Err(..)` for any transport/other failure, so the caller fails closed. + #[cfg(feature = "swaps")] + pub(crate) async fn swap_tx_confirmations( + &self, txid: &Txid, + ) -> std::io::Result> { + let rpc_client = match self { + BitcoindClient::Rpc { rpc_client, .. } => Arc::clone(rpc_client), + BitcoindClient::Rest { rpc_client, .. } => Arc::clone(rpc_client), + }; + // `getrawtransaction` expects the txid in RPC/display (big-endian) order — + // exactly `Txid`'s `Display`. `consensus::encode::serialize_hex` emits the + // INTERNAL (little-endian) bytes, i.e. the REVERSED hex, which bitcoind rejects + // with -5 "No such transaction". That -5 is mapped to `Ok(None)` below, so the + // swap watcher would mistake EVERY confirmed opening tx for NotFound and never + // arm the confirmation/CSV ladder — wedging every swap on the bitcoind backend. + let txid_hex = txid.to_string(); + let txid_json = serde_json::json!(txid_hex); + let verbose_json = serde_json::json!(true); + match rpc_client + .call_method::( + "getrawtransaction", + &[txid_json, verbose_json], + ) + .await + { + Ok(resp) => Ok(Some(resp.0)), + Err(e) => match e.into_inner() { + Some(inner) => { + let rpc_error_res: Result, _> = inner.downcast(); + + match rpc_error_res { + Ok(rpc_error) => { + // -5 == "No such mempool or blockchain transaction". + if rpc_error.code == -5 { + Ok(None) + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, rpc_error)) + } + }, + Err(_) => Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to process verbose getrawtransaction response", + )), + } + }, + None => Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to process verbose getrawtransaction response", + )), + }, + } + } + /// Retrieves the raw mempool. pub(crate) async fn get_raw_mempool(&self) -> std::io::Result> { match self { @@ -678,6 +740,21 @@ impl TryInto for JsonResponse { } } +/// Confirmation depth parsed from a verbose `getrawtransaction` result +/// (Peerswap native primitive B5). The `confirmations` field is absent for an +/// unconfirmed (mempool) transaction, which we map to `0`. +#[cfg(feature = "swaps")] +pub(crate) struct SwapTxConfirmationResponse(pub u32); + +#[cfg(feature = "swaps")] +impl TryInto for JsonResponse { + type Error = std::io::Error; + fn try_into(self) -> std::io::Result { + let confirmations = self.0["confirmations"].as_u64().unwrap_or(0); + Ok(SwapTxConfirmationResponse(confirmations as u32)) + } +} + pub struct GetRawMempoolResponse(Vec); impl TryInto for JsonResponse { From b251258c21d72e7f42d53af0bdde382b479f9ba1 Mon Sep 17 00:00:00 2001 From: tonible14012002 Date: Tue, 30 Jun 2026 19:57:57 +0700 Subject: [PATCH 7/9] =?UTF-8?q?feat(swaps):=20native=20PeerSwap=20on-chain?= =?UTF-8?q?=20primitives=20(B1=E2=80=93B7)=20behind=20the=20`swaps`=20feat?= =?UTF-8?q?ure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the on-chain building blocks the native PeerSwap engine (modules/ldk-node in the consumer) needs, all gated behind a new `swaps` cargo feature so the default build is byte-for-byte unaffected: - B1–B3 funding: create_swap_funding_tx (P2WSH HTLC opening, signed, not broadcast), swap_list_confirmed_utxos, swap_sign_psbt (wallet/mod.rs). - B4 broadcast: broadcast_swap_tx over the bounded broadcast queue (tx_broadcaster.rs). - B5 reorg-aware per-txid confirmation tracking: watch_txid + get_tx_confirmations → TxStatus/ChainStatus + derive_tx_status, with a swap_query_tx backed by whichever chain source is configured (Esplora/Electrum/Bitcoind) and FAIL-CLOSED (NoChainSource) when it cannot answer (chain/mod.rs, chain/electrum.rs). - B6 feerate: estimate_onchain_feerate → source-bearing FeerateQuote so callers can refuse a stale/fallback estimate (fee_estimator.rs). - B7 discovery: swap-capability custom gossip plumbing (custom_gossip.rs). - Builder wiring (builder.rs), public surface (lib.rs). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 3 + src/builder.rs | 2 + src/chain/electrum.rs | 60 +++++ src/chain/mod.rs | 507 ++++++++++++++++++++++++++++++++++++++++++ src/custom_gossip.rs | 12 +- src/fee_estimator.rs | 156 +++++++++++++ src/lib.rs | 152 +++++++++++++ src/tx_broadcaster.rs | 11 + src/wallet/mod.rs | 197 +++++++++++++++- 9 files changed, 1097 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dcb7d022ff..708acd2789 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ panic = 'abort' # Abort on panic [features] default = [] +# Peerswap native primitives (B-series). Empty for now; gates all swap +# additions so the default build is byte-for-byte unaffected. +swaps = [] [dependencies] lightning = { version = "0.1.0", features = ["std"] } diff --git a/src/builder.rs b/src/builder.rs index 229ce6d04e..9c63897f08 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1721,6 +1721,8 @@ fn build_with_store_internal( payment_store, is_listening, node_metrics, + #[cfg(feature = "swaps")] + swap_tx_watch: Arc::new(crate::chain::SwapTxWatch::new()), }) } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 6e62d9c081..ad2b0a515e 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -221,6 +221,66 @@ impl ElectrumRuntimeClient { } } + /// Reorg-aware confirmation query for an ARBITRARY `txid` via its watched + /// scriptPubKey's history (Peerswap native primitive B5). + /// + /// Electrum locates a transaction through its scriptHash history (not by + /// txid), so the watched output script is required. Any client/transport/ + /// task failure yields [`RawTxObservation::Unreachable`] so the caller fails + /// closed (never a falsely-confirmed result, E6). + #[cfg(feature = "swaps")] + pub(crate) async fn swap_query_tx( + &self, txid: Txid, script_pubkey: bitcoin::ScriptBuf, + ) -> crate::chain::RawTxObservation { + use crate::chain::RawTxObservation; + + let electrum_client = Arc::clone(&self.electrum_client); + + let spawn_fut = self.runtime.spawn_blocking(move || { + let history = electrum_client.script_get_history(script_pubkey.as_script())?; + let tip = electrum_client.block_headers_subscribe()?; + Ok::<_, electrum_client::Error>((history, tip.height)) + }); + + let (history, tip_height) = match spawn_fut.await { + Ok(Ok(result)) => result, + Ok(Err(e)) => { + log_error!( + self.logger, + "swap_query_tx: Electrum query failed for {}: {}", + txid, + e + ); + return RawTxObservation::Unreachable; + }, + Err(e) => { + log_error!( + self.logger, + "swap_query_tx: Electrum task join failed for {}: {}", + txid, + e + ); + return RawTxObservation::Unreachable; + }, + }; + + match history.into_iter().find(|entry| entry.tx_hash == txid) { + Some(entry) => { + // Electrum reports height 0 (unconfirmed) or -1 (unconfirmed with + // unconfirmed parents); both mean "in the mempool". + if entry.height <= 0 { + RawTxObservation::InMempool + } else { + let height = entry.height as u32; + let confirmations = + (tip_height as u32).saturating_sub(height).saturating_add(1); + RawTxObservation::Confirmed { height: Some(height), confirmations } + } + }, + None => RawTxObservation::NotFound, + } + } + pub(crate) async fn get_fee_rate_cache_update( &self, ) -> Result, Error> { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index c3d5fdedc5..c54271921b 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -38,6 +38,8 @@ use lightning_block_sync::gossip::UtxoSource; use lightning_block_sync::init::{synchronize_listeners, validate_best_block_header}; use lightning_block_sync::poll::{ChainPoller, ChainTip, ValidatedBlockHeader}; use lightning_block_sync::{BlockSourceErrorKind, SpvClient}; +#[cfg(feature = "swaps")] +use lightning_block_sync::BlockSource; use bdk_esplora::EsploraAsyncExt; use bdk_wallet::Update as BdkUpdate; @@ -232,6 +234,18 @@ pub(crate) enum ChainSource { } impl ChainSource { + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). Used by [`crate::Node`] to surface + /// source-bearing swap feerate quotes. + #[cfg(feature = "swaps")] + pub(crate) fn fee_estimator(&self) -> &Arc { + match self { + Self::Esplora { fee_estimator, .. } => fee_estimator, + Self::Electrum { fee_estimator, .. } => fee_estimator, + Self::Bitcoind { fee_estimator, .. } => fee_estimator, + } + } + pub(crate) fn new_esplora( server_url: String, sync_config: EsploraSyncConfig, onchain_wallet: Arc, fee_estimator: Arc, tx_broadcaster: Arc, @@ -1626,6 +1640,154 @@ impl ChainSource { }, } } + + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` (Peerswap + /// native primitive B5). + /// + /// Unlike the wallet-owned confirmation lookups, this works on a + /// counterparty's swap opening tx that the local wallet does not own. It + /// returns a backend-agnostic [`RawTxObservation`]; the caller + /// ([`crate::Node::get_tx_confirmations`]) folds in the previously-observed + /// confirmation anchor to distinguish a first `Mempool`/`Dropped` sighting + /// from a `Reorged` un-confirmation. + /// + /// FAIL-CLOSED (E6): any chain source that cannot answer — an unstarted + /// backend client, a transport error, or a missing scriptPubKey for the + /// Electrum scriptHash lookup — yields [`RawTxObservation::Unreachable`], so + /// the public API never reports a falsely-confirmed result. + #[cfg(feature = "swaps")] + pub(crate) async fn swap_query_tx( + &self, txid: Txid, script_pubkey: Option<&ScriptBuf>, + ) -> RawTxObservation { + match self { + Self::Esplora { esplora_client, logger, .. } => { + let status = match esplora_client.get_tx_status(&txid).await { + Ok(status) => status, + Err(esplora_client::Error::HttpResponse { status: 404, .. }) => { + // Definitive "not in the chain or mempool" answer. + return RawTxObservation::NotFound; + }, + Err(e) => { + log_error!( + logger, + "swap_query_tx: Esplora status query failed for {}: {}", + txid, + e + ); + return RawTxObservation::Unreachable; + }, + }; + if !status.confirmed { + return RawTxObservation::InMempool; + } + let height = match status.block_height { + Some(height) => height, + None => { + log_error!( + logger, + "swap_query_tx: Esplora reported a confirmed tx {} without a block height", + txid + ); + return RawTxObservation::Unreachable; + }, + }; + // B5 LOW-2: the confirming-block height and the tip come from two + // separate Esplora calls; a block/reorg in the gap can make them + // inconsistent. Detect the one observable inconsistency — a tip BELOW + // the tx's confirming block (impossible on a single consistent chain) + // — and FAIL CLOSED (treat as unverifiable) rather than reporting a + // bogus `1`-confirmation from the saturating arithmetic. The benign + // gap (tip one block ahead of the status snapshot) only over-counts + // confirmations by ≤1, which errs on the safe/late side for deadlines. + match esplora_client.get_height().await { + Ok(tip_height) if tip_height >= height => { + let confirmations = + tip_height.saturating_sub(height).saturating_add(1); + RawTxObservation::Confirmed { height: Some(height), confirmations } + }, + Ok(tip_height) => { + log_error!( + logger, + "swap_query_tx: Esplora tip {} below confirming-block height {} for {} (reorg/race); failing closed", + tip_height, + height, + txid + ); + RawTxObservation::Unreachable + }, + Err(e) => { + log_error!(logger, "swap_query_tx: Esplora tip query failed: {}", e); + RawTxObservation::Unreachable + }, + } + }, + Self::Electrum { electrum_runtime_status, logger, .. } => { + let script_pubkey = match script_pubkey { + Some(script_pubkey) => script_pubkey.clone(), + None => { + log_error!( + logger, + "swap_query_tx: Electrum backend requires a watched scriptPubKey for {} (register via watch_txid)", + txid + ); + return RawTxObservation::Unreachable; + }, + }; + let client = match electrum_runtime_status.read().unwrap().client() { + Some(client) => client, + None => { + log_error!(logger, "swap_query_tx: Electrum chain source not started"); + return RawTxObservation::Unreachable; + }, + }; + client.swap_query_tx(txid, script_pubkey).await + }, + Self::Bitcoind { api_client, latest_chain_tip, logger, .. } => { + match api_client.swap_tx_confirmations(&txid).await { + Ok(Some(0)) => RawTxObservation::InMempool, + Ok(Some(confirmations)) => { + // `getrawtransaction` returns the depth but not the height; derive + // it as `tip - (confs - 1)`. B5 LOW-2: read a FRESH best-chain tip + // (`get_best_block`) rather than the cached `latest_chain_tip`, + // which can lag the real tip and yield a height that is too low — + // and thus a CSV/claim deadline armed slightly EARLY. A fresh (or + // even a one-block-stale-newer) tip can only err on the LATE/safe + // side. Fail-soft on the HEIGHT ONLY: the depth is already + // authoritative, so on a tip-read error we fall back to the cached + // tip rather than failing the whole query closed. + let tip_height = match api_client.get_best_block().await { + Ok((_, Some(h))) => Some(h), + Ok((_, None)) => { + latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) + }, + Err(e) => { + log_error!( + logger, + "swap_query_tx: Bitcoind fresh-tip read failed for {} ({:?}); falling back to cached tip for height", + txid, + e + ); + latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) + }, + }; + let height = tip_height + .map(|t| t.saturating_sub(confirmations.saturating_sub(1))); + RawTxObservation::Confirmed { height, confirmations } + }, + Ok(None) => RawTxObservation::NotFound, + Err(e) => { + log_error!( + logger, + "swap_query_tx: Bitcoind query failed for {}: {}", + txid, + e + ); + RawTxObservation::Unreachable + }, + } + }, + } + } } impl Filter for ChainSource { @@ -1667,3 +1829,348 @@ fn periodically_archive_fully_resolved_monitors( } Ok(()) } + +// ============================================================================ +// Peerswap native primitive B5 — reorg-aware per-txid confirmation tracking. +// +// `register_tx`/`onchain_tx_confirmations` only cover wallet-owned txids; a +// swap taker must verify the *counterparty's* opening tx, which the wallet does +// not own. The types and helpers below add a brand-new, reorg-aware, per-txid +// chain query over whichever chain source the deployment configured +// (Esplora/Electrum/Bitcoind), and FAIL CLOSED (never a falsely-confirmed +// result) when that source cannot answer (E6). Everything here is gated behind +// the `swaps` cargo feature so the default build is byte-for-byte unaffected. +// ============================================================================ + +/// Reorg-aware chain status of a watched transaction (Peerswap native +/// primitive B5). +/// +/// [`ChainStatus::NoChainSource`] is the fail-closed sentinel returned when the +/// configured chain source cannot answer — it is never conflated with a +/// confirmed result (E6). [`ChainStatus::Reorged`] is reported when a tx that +/// was previously observed confirmed is no longer in the best chain, so the +/// caller can re-anchor CSV/claim deadlines (F4). +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChainStatus { + /// Included in a block on the current best chain. + Confirmed, + /// Known to the chain source but still unconfirmed (in the mempool). + Mempool, + /// Previously observed confirmed, but no longer in the best chain (re-orged + /// back to the mempool or evicted). Deadlines must be re-anchored. + Reorged, + /// Unknown to the chain source and never observed confirmed (never broadcast + /// or evicted from the mempool before confirming). + Dropped, + /// The chain source is unconfigured/unreachable and could not answer. The + /// caller MUST treat this as "unverifiable", never as confirmed + /// (fail-closed, E6). + NoChainSource, +} + +#[cfg(feature = "swaps")] +impl ChainStatus { + /// Stable lowercase string form for capability payloads and logs. + pub fn as_str(&self) -> &'static str { + match self { + ChainStatus::Confirmed => "confirmed", + ChainStatus::Mempool => "mempool", + ChainStatus::Reorged => "reorged", + ChainStatus::Dropped => "dropped", + ChainStatus::NoChainSource => "no_chain_source", + } + } +} + +/// Reorg-aware confirmation/eviction status of a watched transaction +/// (Peerswap native primitive B5). +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TxStatus { + /// Confirmation depth on the current best chain (`0` when unconfirmed, + /// reorged, dropped, or unverifiable). + pub confirmations: u32, + /// Height of the confirming block, re-derived from the current best chain + /// (`None` when unconfirmed/reorged/dropped/unverifiable). + pub height: Option, + /// Reorg-aware chain status. + pub status: ChainStatus, +} + +/// Backend-agnostic raw observation of a `txid` against a chain source, before +/// the previously-observed confirmation anchor is folded in (Peerswap B5). +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RawTxObservation { + /// Included in a best-chain block at the given height/depth. + Confirmed { height: Option, confirmations: u32 }, + /// Known to the chain source but unconfirmed. + InMempool, + /// Unknown to the chain source. + NotFound, + /// The chain source could not answer (fail-closed sentinel, E6). + Unreachable, +} + +/// Fold a raw observation together with whether the tx was previously observed +/// confirmed into the reorg-aware [`TxStatus`] (Peerswap B5). +/// +/// Pure function — unit-tested independently of any live chain source. The +/// load-bearing invariant: a [`RawTxObservation::Unreachable`] can never become +/// [`ChainStatus::Confirmed`], regardless of prior confirmation history. +#[cfg(feature = "swaps")] +pub(crate) fn derive_tx_status( + observation: RawTxObservation, previously_confirmed: bool, +) -> TxStatus { + match observation { + RawTxObservation::Confirmed { height, confirmations } => TxStatus { + // A tx in the tip block is 1 confirmation deep, never 0. + confirmations: confirmations.max(1), + height, + status: ChainStatus::Confirmed, + }, + RawTxObservation::InMempool => TxStatus { + confirmations: 0, + height: None, + status: if previously_confirmed { + ChainStatus::Reorged + } else { + ChainStatus::Mempool + }, + }, + RawTxObservation::NotFound => TxStatus { + confirmations: 0, + height: None, + status: if previously_confirmed { + ChainStatus::Reorged + } else { + ChainStatus::Dropped + }, + }, + RawTxObservation::Unreachable => TxStatus { + confirmations: 0, + height: None, + status: ChainStatus::NoChainSource, + }, + } +} + +/// In-memory registry of swap txids being watched (Peerswap B5). +/// +/// Stores the scriptPubKey (required by the Electrum scriptHash lookup) and the +/// last observed confirmation height, which lets +/// [`crate::Node::get_tx_confirmations`] distinguish a first unconfirmed +/// sighting from a reorg-induced un-confirmation. Durable reorg state +/// additionally lives in the native `swap.db` on the consumer side; this cache +/// is best-effort and is rebuilt by re-registering after a restart. +#[cfg(feature = "swaps")] +pub(crate) struct SwapTxWatch { + entries: Mutex>, +} + +#[cfg(feature = "swaps")] +#[derive(Clone)] +struct SwapWatchEntry { + script_pubkey: ScriptBuf, + last_confirmed_height: Option, +} + +#[cfg(feature = "swaps")] +impl SwapTxWatch { + pub(crate) fn new() -> Self { + Self { entries: Mutex::new(HashMap::new()) } + } + + /// Register (idempotently) a txid + its scriptPubKey for watching. A repeat + /// registration refreshes the scriptPubKey but preserves the prior + /// confirmation anchor, so reorg detection survives a re-arm. + pub(crate) fn register(&self, txid: Txid, script_pubkey: ScriptBuf) { + let mut entries = self.entries.lock().unwrap(); + entries + .entry(txid) + .and_modify(|entry| entry.script_pubkey = script_pubkey.clone()) + .or_insert(SwapWatchEntry { script_pubkey, last_confirmed_height: None }); + } + + /// Drop the watch entry for `txid` (B5 LOW-1). Without this the map grows + /// unbounded for the process lifetime — every distinct watched txid (each + /// swap's opening + spend txs) accumulates forever. The consumer calls this + /// once a swap reaches a terminal, settled state and no longer needs reorg + /// tracking. A no-op if `txid` was never registered. + pub(crate) fn unregister(&self, txid: &Txid) { + self.entries.lock().unwrap().remove(txid); + } + + /// Number of currently-watched txids (test/observability only). + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.entries.lock().unwrap().len() + } + + /// The watched scriptPubKey for `txid`, if registered. + pub(crate) fn script_pubkey(&self, txid: &Txid) -> Option { + self.entries.lock().unwrap().get(txid).map(|entry| entry.script_pubkey.clone()) + } + + /// Whether `txid` was ever observed confirmed (arms reorg detection). + pub(crate) fn previously_confirmed(&self, txid: &Txid) -> bool { + self.entries + .lock() + .unwrap() + .get(txid) + .map_or(false, |entry| entry.last_confirmed_height.is_some()) + } + + /// Persist the latest confirmation anchor after a query so subsequent + /// queries can detect a reorg/un-confirmation. Only a fresh confirmation + /// advances the anchor; a non-confirmed status never disarms it. + pub(crate) fn record(&self, txid: &Txid, status: &TxStatus) { + if status.status == ChainStatus::Confirmed { + if let Some(entry) = self.entries.lock().unwrap().get_mut(txid) { + entry.last_confirmed_height = status.height; + } + } + } +} + +#[cfg(all(test, feature = "swaps"))] +mod swap_b5_tests { + use super::{derive_tx_status, ChainStatus, RawTxObservation, SwapTxWatch}; + use bitcoin::hashes::Hash; + use bitcoin::{ScriptBuf, Txid}; + + fn dummy_txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + #[test] + fn confirmed_reports_depth_and_height() { + let status = derive_tx_status( + RawTxObservation::Confirmed { height: Some(100), confirmations: 6 }, + false, + ); + assert_eq!(status.status, ChainStatus::Confirmed); + assert_eq!(status.confirmations, 6); + assert_eq!(status.height, Some(100)); + } + + #[test] + fn confirmed_depth_is_floored_to_one() { + // A tx in the tip block is 1 confirmation deep, never 0. + let status = derive_tx_status( + RawTxObservation::Confirmed { height: Some(100), confirmations: 0 }, + false, + ); + assert_eq!(status.status, ChainStatus::Confirmed); + assert_eq!(status.confirmations, 1); + } + + #[test] + fn first_sighting_distinguishes_mempool_from_dropped() { + let mempool = derive_tx_status(RawTxObservation::InMempool, false); + assert_eq!(mempool.status, ChainStatus::Mempool); + assert_eq!(mempool.confirmations, 0); + assert_eq!(mempool.height, None); + + let dropped = derive_tx_status(RawTxObservation::NotFound, false); + assert_eq!(dropped.status, ChainStatus::Dropped); + assert_eq!(dropped.confirmations, 0); + assert_eq!(dropped.height, None); + } + + #[test] + fn unconfirmation_after_confirm_is_reorg() { + // Previously confirmed, now back to the mempool or gone => Reorged, + // not Mempool/Dropped, so the caller re-anchors deadlines (F4). + let back_to_mempool = derive_tx_status(RawTxObservation::InMempool, true); + assert_eq!(back_to_mempool.status, ChainStatus::Reorged); + assert_eq!(back_to_mempool.confirmations, 0); + + let evicted = derive_tx_status(RawTxObservation::NotFound, true); + assert_eq!(evicted.status, ChainStatus::Reorged); + assert_eq!(evicted.confirmations, 0); + } + + #[test] + fn unreachable_fails_closed_and_is_never_confirmed() { + // The load-bearing E6 invariant: an unanswerable chain source is never + // reported as confirmed, regardless of prior confirmation history. + for previously_confirmed in [false, true] { + let status = + derive_tx_status(RawTxObservation::Unreachable, previously_confirmed); + assert_eq!(status.status, ChainStatus::NoChainSource); + assert_ne!(status.status, ChainStatus::Confirmed); + assert_eq!(status.confirmations, 0); + assert_eq!(status.height, None); + } + } + + #[test] + fn unreachable_status_string_is_stable() { + assert_eq!(ChainStatus::NoChainSource.as_str(), "no_chain_source"); + assert_eq!(ChainStatus::Confirmed.as_str(), "confirmed"); + assert_eq!(ChainStatus::Reorged.as_str(), "reorged"); + assert_eq!(ChainStatus::Dropped.as_str(), "dropped"); + assert_eq!(ChainStatus::Mempool.as_str(), "mempool"); + } + + #[test] + fn watch_registry_tracks_spk_and_reorg_anchor() { + let watch = SwapTxWatch::new(); + let txid = dummy_txid(7); + let spk = ScriptBuf::from_bytes(vec![0x00, 0x14, 0x11, 0x22, 0x33]); + + // Unregistered: no scriptPubKey, not previously confirmed. + assert!(watch.script_pubkey(&txid).is_none()); + assert!(!watch.previously_confirmed(&txid)); + + watch.register(txid, spk.clone()); + assert_eq!(watch.script_pubkey(&txid), Some(spk)); + assert!(!watch.previously_confirmed(&txid)); + + // Recording a confirmation arms the reorg anchor. + let confirmed = derive_tx_status( + RawTxObservation::Confirmed { height: Some(200), confirmations: 3 }, + false, + ); + watch.record(&txid, &confirmed); + assert!(watch.previously_confirmed(&txid)); + + // A later mempool sighting for the now-armed txid derives Reorged. + let reorged = + derive_tx_status(RawTxObservation::InMempool, watch.previously_confirmed(&txid)); + assert_eq!(reorged.status, ChainStatus::Reorged); + + // Recording a non-confirmed status does not disarm the anchor. + watch.record(&txid, &reorged); + assert!(watch.previously_confirmed(&txid)); + } + + #[test] + fn unregister_drops_the_watch_entry_and_is_idempotent() { + // B5 LOW-1: the watch map must not grow unbounded — a terminalized swap's + // entry is dropped, and unregistering an unknown txid is a harmless no-op. + let watch = SwapTxWatch::new(); + let a = dummy_txid(1); + let b = dummy_txid(2); + let spk = ScriptBuf::from_bytes(vec![0x00, 0x14, 0xaa, 0xbb]); + + watch.register(a, spk.clone()); + watch.register(b, spk.clone()); + assert_eq!(watch.len(), 2); + + watch.unregister(&a); + assert_eq!(watch.len(), 1, "the terminalized txid's entry is dropped"); + assert!(watch.script_pubkey(&a).is_none()); + assert!(watch.script_pubkey(&b).is_some(), "unrelated entry untouched"); + + // Idempotent: dropping the same (or an unknown) txid again is a no-op. + watch.unregister(&a); + watch.unregister(&dummy_txid(99)); + assert_eq!(watch.len(), 1); + + watch.unregister(&b); + assert_eq!(watch.len(), 0); + } +} diff --git a/src/custom_gossip.rs b/src/custom_gossip.rs index 4eb39db890..c1540acc13 100644 --- a/src/custom_gossip.rs +++ b/src/custom_gossip.rs @@ -124,6 +124,14 @@ where *our_metadata = Some(metadata); } + /// Get OUR OWN advertised metadata blob (the one broadcast to peers), if set. + /// Distinct from [`get_all_metadata`], which returns PEERS' received blobs and + /// NEVER our own — so this is the only way for the owning node to read back what + /// it is currently advertising (needed for a correct read-merge-write of our blob). + pub fn get_our_metadata(&self) -> Option> { + self.our_metadata.lock().unwrap().clone() + } + /// Get metadata for a specific node pub fn get_node_metadata(&self, node_id: &PublicKey) -> Option { let metadata_store = self.node_metadata.lock().unwrap(); @@ -256,8 +264,8 @@ where #[cfg(test)] mod tests { use super::*; - use crate::logger::test_logger; use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning::util::test_utils::TestLogger; use lightning::util::ser::{Readable, Writeable}; use std::io::Cursor; @@ -282,7 +290,7 @@ mod tests { #[test] fn test_custom_gossip_handler() { - let logger = test_logger(); + let logger = Arc::new(TestLogger::new()); let handler = CustomGossipMessageHandler::new(logger); // Test setting our metadata diff --git a/src/fee_estimator.rs b/src/fee_estimator.rs index da7833d835..481350ca64 100644 --- a/src/fee_estimator.rs +++ b/src/fee_estimator.rs @@ -157,3 +157,159 @@ pub(crate) fn apply_post_estimation_adjustments( _ => estimated_rate, } } + +/// Public fee-priority selector for on-chain swap transactions (Peerswap +/// native primitives, B-series). +/// +/// This is the **public** surface used by swap code to ask for a fee rate +/// without exposing the crate-internal [`ConfirmationTarget`] enum. Each +/// variant maps onto an existing internal target via [`From`], so no new +/// `ConfirmationTarget` variant is introduced and every existing exhaustive +/// match is left untouched. +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum SwapFeeTarget { + /// Fee target for broadcasting a swap funding (HTLC opening) transaction. + /// + /// Maps to [`ConfirmationTarget::ChannelFunding`] so the funding output + /// confirms promptly (~3 blocks) and the swap can proceed without stalling. + Funding, + /// Fee target for time-sensitive claim/sweep transactions. + /// + /// A swap claim is bounded by an on-chain timelock, so it must confirm + /// urgently. Maps to [`LdkConfirmationTarget::UrgentOnChainSweep`]. + Claim, + /// Fee target for refund / cooperative-spend transactions. + /// + /// Less time-critical than a [`SwapFeeTarget::Claim`]; maps to the standard + /// [`ConfirmationTarget::OnchainPayment`] priority. + Refund, +} + +#[cfg(feature = "swaps")] +impl From for ConfirmationTarget { + fn from(value: SwapFeeTarget) -> Self { + match value { + SwapFeeTarget::Funding => ConfirmationTarget::ChannelFunding, + SwapFeeTarget::Claim => { + ConfirmationTarget::Lightning(LdkConfirmationTarget::UrgentOnChainSweep) + }, + SwapFeeTarget::Refund => ConfirmationTarget::OnchainPayment, + } + } +} + +/// Provenance of a swap feerate estimate (Peerswap native primitive B6 / +/// plan FIX-B). +/// +/// Lets a swap caller distinguish a live estimate sourced from the chain +/// backend from a static fallback/relay-floor value, so it can refuse to fund +/// (fail-closed) on an estimate it does not trust. +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SwapFeerateSource { + /// A live estimate sourced from the chain backend's fee-rate cache. + Native, + /// No live estimate was available; a per-target fallback rate (or the + /// `FEERATE_FLOOR_SATS_PER_KW` relay floor) was used instead. + Static, +} + +/// A swap feerate estimate carrying its [`SwapFeerateSource`] provenance +/// (Peerswap native primitive B6 / plan FIX-B). +/// +/// This is intentionally NOT a bare `u64`/[`FeeRate`]: swap funding decisions +/// are fail-closed, so the consumer must be able to tell a live estimate from a +/// fallback/floor before committing funds. +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FeerateQuote { + /// The estimated feerate in satoshis per virtual byte (rounded up so the + /// transaction is never under-funded relative to the estimate). + pub sat_vb: u64, + /// Whether `sat_vb` came from a live estimate or a static fallback/floor. + pub source: SwapFeerateSource, +} + +#[cfg(feature = "swaps")] +impl OnchainFeeEstimator { + /// Estimate the on-chain fee rate for a swap transaction at the requested + /// [`SwapFeeTarget`] priority. + /// + /// Thin wrapper over [`FeeEstimator::estimate_fee_rate`] that maps the + /// public [`SwapFeeTarget`] onto the internal [`ConfirmationTarget`]. The + /// returned [`FeeRate`] is subject to the same `FEERATE_FLOOR_SATS_PER_KW` + /// lower bound as every other estimate, and falls back to the per-target + /// fallback rate when the cache is empty. + pub(crate) fn estimate_swap_fee_rate(&self, target: SwapFeeTarget) -> FeeRate { + self.estimate_fee_rate(target.into()) + } + + /// Source-bearing swap feerate estimate (B6 / FIX-B). + /// + /// Returns the estimate in sat/vB together with its [`SwapFeerateSource`]: + /// [`SwapFeerateSource::Native`] when a live cached estimate exists for the + /// mapped target, [`SwapFeerateSource::Static`] when the per-target + /// fallback / relay floor had to be used (cache empty). Callers MUST treat + /// a `Static` quote as untrusted for fail-closed funding decisions. + pub(crate) fn estimate_swap_feerate_quote(&self, target: SwapFeeTarget) -> FeerateQuote { + let conf_target: ConfirmationTarget = target.into(); + let source = if self.fee_rate_cache.read().unwrap().contains_key(&conf_target) { + SwapFeerateSource::Native + } else { + SwapFeerateSource::Static + }; + let rate = self.estimate_fee_rate(conf_target); + FeerateQuote { sat_vb: rate.to_sat_per_vb_ceil(), source } + } +} + +#[cfg(all(test, feature = "swaps"))] +mod swap_b6_tests { + use super::*; + + // An empty cache must yield a `Static` quote (fallback/floor), never a + // `Native` one — the fail-closed default for swap funding decisions. + #[test] + fn empty_cache_quote_is_static() { + let estimator = OnchainFeeEstimator::new(); + for target in [SwapFeeTarget::Funding, SwapFeeTarget::Claim, SwapFeeTarget::Refund] { + let quote = estimator.estimate_swap_feerate_quote(target); + assert_eq!(quote.source, SwapFeerateSource::Static); + // The fallback is always at least the relay floor, so sat/vB is > 0. + assert!(quote.sat_vb > 0); + } + } + + // A live cached estimate for the mapped target must yield a `Native` quote + // whose sat/vB reflects the cached rate (here well above the relay floor). + #[test] + fn cached_estimate_quote_is_native() { + let estimator = OnchainFeeEstimator::new(); + let target = SwapFeeTarget::Funding; + let conf_target: ConfirmationTarget = target.into(); + // 2500 sat/kwu == 10 sat/vB, comfortably above FEERATE_FLOOR_SATS_PER_KW. + let mut update = HashMap::new(); + update.insert(conf_target, FeeRate::from_sat_per_kwu(2500)); + estimator.set_fee_rate_cache(update); + + let quote = estimator.estimate_swap_feerate_quote(target); + assert_eq!(quote.source, SwapFeerateSource::Native); + assert_eq!(quote.sat_vb, 10); + } + + // A target absent from a populated cache still fails closed to `Static`. + #[test] + fn missing_target_in_populated_cache_is_static() { + let estimator = OnchainFeeEstimator::new(); + let mut update = HashMap::new(); + update.insert( + Into::::into(SwapFeeTarget::Funding), + FeeRate::from_sat_per_kwu(2500), + ); + estimator.set_fee_rate_cache(update); + + let quote = estimator.estimate_swap_feerate_quote(SwapFeeTarget::Claim); + assert_eq!(quote.source, SwapFeerateSource::Static); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7809cdb4fb..5a780854e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,6 +116,40 @@ pub use event::Event; pub use io::utils::generate_entropy_mnemonic; +/// Public swap fee-priority selector (Peerswap native primitives, B-series). +/// +/// Re-exported from the otherwise-private `fee_estimator` module so swap code +/// can request on-chain fee rates without the crate-internal +/// `ConfirmationTarget` leaking into the public API. +#[cfg(feature = "swaps")] +pub use fee_estimator::SwapFeeTarget; + +/// Public source-bearing swap feerate quote types (Peerswap native primitive +/// B6 / plan FIX-B). Re-exported from the otherwise-private `fee_estimator` +/// module so swap callers can detect estimate provenance (live vs fallback). +#[cfg(feature = "swaps")] +pub use fee_estimator::{FeerateQuote, SwapFeerateSource}; + +/// Public reorg-aware chain-status types for the swap-txid watch primitive +/// (Peerswap native primitives, B5). Re-exported from the otherwise-private +/// `chain` module. +#[cfg(feature = "swaps")] +pub use chain::{ChainStatus, TxStatus}; + +/// Public types appearing in the swap-primitive signatures on [`Node`] +/// (Peerswap native primitives, B-series). Re-exported so the consumer crate +/// can name them without reaching into the (otherwise-private) LDK/bitcoin +/// module paths. +#[cfg(feature = "swaps")] +pub use bitcoin::psbt::Psbt; +/// Swap keypair type returned by [`Node::derive_swap_keypair`] (B7). +#[cfg(feature = "swaps")] +pub use bitcoin::secp256k1::Keypair as SwapKeypair; +/// Confirmed wallet UTXO type returned by [`Node::swap_list_confirmed_utxos`] +/// (B2). +#[cfg(feature = "swaps")] +pub use lightning::events::bump_transaction::Utxo; + #[cfg(feature = "uniffi")] use ffi::*; @@ -206,6 +240,9 @@ pub struct Node { payment_store: Arc, is_listening: Arc, node_metrics: Arc>, + /// Reorg-aware swap-txid watch registry (Peerswap native primitive B5). + #[cfg(feature = "swaps")] + swap_tx_watch: Arc, } impl Node { @@ -939,6 +976,121 @@ impl Node { ) } + /// Registers an arbitrary transaction (e.g. a counterparty's swap opening tx + /// that the local wallet does not own) for reorg-aware confirmation tracking + /// via [`Node::get_tx_confirmations`] (Peerswap native primitive B5). + /// + /// `scriptpubkey` is the output script being watched; it is required by the + /// Electrum chain source (which locates a tx through its scriptHash history) + /// and ignored by the Esplora/Bitcoind backends. Registration is idempotent. + #[cfg(feature = "swaps")] + pub fn watch_txid(&self, txid: bitcoin::Txid, scriptpubkey: bitcoin::ScriptBuf) { + self.swap_tx_watch.register(txid, scriptpubkey); + } + + /// Drops the reorg-aware watch for `txid` registered via [`Node::watch_txid`] + /// (Peerswap native primitive B5, LOW-1). The consumer calls this once a swap + /// reaches a terminal, settled state so the in-memory watch map does not grow + /// unbounded for the process lifetime. A no-op for a txid never watched. + #[cfg(feature = "swaps")] + pub fn unwatch_txid(&self, txid: bitcoin::Txid) { + self.swap_tx_watch.unregister(&txid); + } + + /// Queries the reorg-aware confirmation status of an arbitrary `txid` + /// against the configured chain source (Peerswap native primitive B5). + /// + /// Unlike wallet-owned confirmation lookups, this works on a counterparty's + /// opening tx. Confirmations are re-derived from the tx's *current* + /// best-chain block on every call, so a previously-confirmed tx that has + /// re-orged out is reported as [`ChainStatus::Reorged`] (and a never-confirmed + /// tx gone from the mempool as [`ChainStatus::Dropped`]) with zero + /// confirmations, allowing the caller to re-anchor deadlines (F4). + /// + /// FAIL-CLOSED (E6): if the chain source is unconfigured/unreachable, or an + /// Electrum lookup is attempted for a `txid` never registered via + /// [`Node::watch_txid`], the returned status is [`ChainStatus::NoChainSource`] + /// — never a confirmed result. Callers MUST NOT advance any state that + /// depends on a confirmation they could not verify. + #[cfg(feature = "swaps")] + pub async fn get_tx_confirmations(&self, txid: bitcoin::Txid) -> Result { + let script_pubkey = self.swap_tx_watch.script_pubkey(&txid); + let previously_confirmed = self.swap_tx_watch.previously_confirmed(&txid); + let observation = self.chain_source.swap_query_tx(txid, script_pubkey.as_ref()).await; + let status = chain::derive_tx_status(observation, previously_confirmed); + self.swap_tx_watch.record(&txid, &status); + Ok(status) + } + + /// Builds a fully-signed swap funding (HTLC opening) transaction paying + /// `amount` to `output_script` (e.g. a P2WSH submarine-swap HTLC output) at + /// the feerate implied by `fee_target`, with the supplied `locktime` + /// (Peerswap native primitive B1). + /// + /// The returned [`bitcoin::Transaction`] is signed and persisted but **not** + /// broadcast — call [`Node::broadcast_swap_tx`] to publish it. The public + /// [`SwapFeeTarget`] is used in place of the crate-internal confirmation + /// target so no internal type leaks across the crate boundary. + #[cfg(feature = "swaps")] + pub fn create_swap_funding_tx( + &self, output_script: bitcoin::ScriptBuf, amount: bitcoin::Amount, + fee_target: SwapFeeTarget, locktime: bitcoin::blockdata::locktime::absolute::LockTime, + ) -> Result { + self.wallet.create_swap_funding_tx(output_script, amount, fee_target.into(), locktime) + } + + /// Lists the wallet's confirmed, unspent outputs as [`Utxo`]s for use as + /// swap funding inputs (Peerswap native primitive B2). + #[cfg(feature = "swaps")] + pub fn swap_list_confirmed_utxos(&self) -> Result, Error> { + self.wallet.swap_list_confirmed_utxos() + } + + /// Signs a swap [`Psbt`] with the on-chain wallet, returning the extracted + /// [`bitcoin::Transaction`] (Peerswap native primitive B3). + /// + /// LDK-provided inputs are not finalized by BDK; the caller is responsible + /// for finalizing any swap-script (HTLC) inputs it owns. + #[cfg(feature = "swaps")] + pub fn swap_sign_psbt(&self, psbt: Psbt) -> Result { + self.wallet.swap_sign_psbt(psbt) + } + + /// Enqueues a fully-signed swap transaction for broadcast on the configured + /// chain backend (Peerswap native primitive B4). + /// + /// Fire-and-forget: the transaction is placed on the bounded broadcast queue + /// drained by the chain source and this returns immediately; it does not + /// confirm acceptance by the backend. + #[cfg(feature = "swaps")] + pub fn broadcast_swap_tx(&self, tx: &bitcoin::Transaction) { + self.tx_broadcaster.broadcast_tx(tx); + } + + /// Estimates the on-chain feerate for a swap transaction at the requested + /// [`SwapFeeTarget`] priority, returning a source-bearing [`FeerateQuote`] + /// (Peerswap native primitive B6 / plan FIX-B). + /// + /// The quote's [`SwapFeerateSource`] lets a fail-closed caller distinguish a + /// live backend estimate from a static fallback/relay-floor value and refuse + /// to fund on an untrusted estimate. + #[cfg(feature = "swaps")] + pub fn estimate_onchain_feerate(&self, target: SwapFeeTarget) -> FeerateQuote { + self.chain_source.fee_estimator().estimate_swap_feerate_quote(target) + } + + /// Derives a deterministic swap [`SwapKeypair`] at `index` from a dedicated, + /// swaps-only BIP-32 derivation path (Peerswap native primitive B7). + /// + /// The keypair is NEVER derived from the node identity secret key: it comes + /// from a hardened path reserved exclusively for swaps, isolated from the + /// identity/channel keys. The returned keypair carries both the secret and + /// public key for building and signing swap HTLC scripts. + #[cfg(feature = "swaps")] + pub fn derive_swap_keypair(&self, index: u32) -> Result { + self.keys_manager.derive_swap_keypair(index) + } + /// Returns a payment handler allowing to send and receive on-chain payments. #[cfg(feature = "uniffi")] pub fn onchain_payment(&self) -> Arc { diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 15b135de48..e50658aace 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -47,6 +47,17 @@ where pub(crate) async fn get_broadcast_queue(&self) -> MutexGuard>> { self.queue_receiver.lock().await } + + /// Enqueues a single fully-signed transaction for broadcast (swaps B4). + /// + /// Thin wrapper over the [`BroadcasterInterface::broadcast_transactions`] impl below: + /// it enqueues the transaction onto the bounded broadcast queue drained by the chain + /// source's `process_broadcast_queue` loop. The actual network send happens there, + /// so this returns immediately and does not confirm acceptance by the backend. + #[cfg(feature = "swaps")] + pub(crate) fn broadcast_tx(&self, tx: &Transaction) { + ::broadcast_transactions(self, &[tx]); + } } impl BroadcasterInterface for TransactionBroadcaster diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index fbac1d1b6d..cd78370437 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -50,6 +50,11 @@ use bitcoin::{ WitnessProgram, WitnessVersion, }; +#[cfg(feature = "swaps")] +use bitcoin::bip32::{ChildNumber, Xpriv}; +#[cfg(feature = "swaps")] +use bitcoin::secp256k1::Keypair; + use std::ops::Deref; use std::str::FromStr; use std::sync::{Arc, Mutex}; @@ -284,6 +289,42 @@ where Ok(tx) } + /// Builds a fully-signed funding transaction paying `amount` to an arbitrary `output_script` + /// (e.g. a P2WSH submarine-swap HTLC output) at the fee rate implied by `confirmation_target`, + /// with the supplied `locktime`. The returned [`Transaction`] is signed and persisted but **not** + /// broadcast. + /// + /// This is a thin swaps-gated wrapper over [`Wallet::create_funding_transaction`]; it does not + /// alter the existing behaviour of that method in any way. + #[cfg(feature = "swaps")] + pub(crate) fn create_swap_funding_tx( + &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, + locktime: LockTime, + ) -> Result { + self.create_funding_transaction(output_script, amount, confirmation_target, locktime) + } + + /// Lists the wallet's confirmed, unspent outputs as [`Utxo`]s. + /// + /// This is a thin swaps-gated inherent wrapper over the [`WalletSource::list_confirmed_utxos`] + /// trait method. Unlike the trait method (whose error type is `()`), it surfaces a real + /// [`Error`] so swap call sites get a meaningful failure value. + #[cfg(feature = "swaps")] + pub(crate) fn swap_list_confirmed_utxos(&self) -> Result, Error> { + WalletSource::list_confirmed_utxos(self).map_err(|()| Error::WalletOperationFailed) + } + + /// Signs a PSBT with the BDK wallet, returning the extracted [`Transaction`]. + /// + /// This is a thin swaps-gated inherent wrapper over the [`WalletSource::sign_psbt`] trait + /// method. Unlike the trait method (whose error type is `()`), it surfaces a real [`Error`] so + /// swap call sites get a meaningful failure value. As with the trait method, LDK-provided inputs + /// are not finalized by BDK and the `finalized` bool is intentionally ignored. + #[cfg(feature = "swaps")] + pub(crate) fn swap_sign_psbt(&self, psbt: Psbt) -> Result { + WalletSource::sign_psbt(self, psbt).map_err(|()| Error::WalletOperationFailed) + } + pub(crate) fn get_new_address(&self) -> Result { let mut locked_wallet = self.inner.lock().unwrap(); let mut locked_persister = self.persister.lock().unwrap(); @@ -786,6 +827,14 @@ where inner: KeysManager, wallet: Arc>, logger: L, + /// Dedicated swap-key derivation master (Peerswap native primitive B7). + /// + /// Derived from the wallet seed at a hardened BIP-32 index reserved + /// exclusively for swaps. It is fully isolated from the node identity + /// secret key (which LDK derives at the low reserved children of the same + /// master), so a swap keypair can NEVER coincide with the node identity. + #[cfg(feature = "swaps")] + swap_master_xprv: Xpriv, } impl WalletKeysManager @@ -803,7 +852,15 @@ where wallet: Arc>, logger: L, ) -> Self { let inner = KeysManager::new(seed, starting_time_secs, starting_time_nanos); - Self { inner, wallet, logger } + #[cfg(feature = "swaps")] + let swap_master_xprv = Self::derive_swap_master_xprv(seed); + Self { + inner, + wallet, + logger, + #[cfg(feature = "swaps")] + swap_master_xprv, + } } pub fn sign_message(&self, msg: &[u8]) -> String { @@ -817,6 +874,66 @@ where pub fn verify_signature(&self, msg: &[u8], sig: &str, pkey: &PublicKey) -> bool { message_signing::verify(msg, sig, pkey) } + + /// Hardened BIP-32 child index of the dedicated swap-key domain (B7). + /// + /// Value is the ASCII bytes of `"swap"` (`0x73776170`), which is `< 2^31` + /// so it is a valid hardened index. It sits far outside the low children + /// (`0..=6`) that LDK's `KeysManager` reserves for the node identity, + /// channel, destination, shutdown, and inbound-payment keys — guaranteeing + /// the swap key tree never overlaps the node identity secret key. + #[cfg(feature = "swaps")] + const SWAP_KEY_HARDENED_CHILD_INDEX: u32 = 0x7377_6170; + + /// Derives the dedicated swap-domain master xpriv from the wallet `seed`. + /// + /// BIP-32 child-key derivation is network-independent for the secret + /// material, so the fixed network used to construct the master only affects + /// the (unused) serialization version bytes — never the derived keys. + #[cfg(feature = "swaps")] + fn derive_swap_master_xprv(seed: &[u8; 32]) -> Xpriv { + let secp = Secp256k1::new(); + let master = Xpriv::new_master(Network::Bitcoin, seed) + .expect("a 32-byte seed is always a valid BIP-32 master key"); + master + .derive_priv( + &secp, + &[ChildNumber::Hardened { index: Self::SWAP_KEY_HARDENED_CHILD_INDEX }], + ) + .expect("hardened derivation from a valid master key is infallible") + } + + /// Derives a deterministic swap [`Keypair`] at `index` from the dedicated, + /// swaps-only BIP-32 path (B7). + /// + /// The key is derived from [`Self::swap_master_xprv`], i.e. a hardened path + /// reserved exclusively for swaps; it is NEVER derived from the node + /// identity secret key. The returned [`Keypair`] carries both the secret + /// and the public key so callers can build and sign swap HTLC scripts. + #[cfg(feature = "swaps")] + pub(crate) fn derive_swap_keypair(&self, index: u32) -> Result { + swap_keypair_from_master(&self.swap_master_xprv, index).map_err(|e| { + log_error!(self.logger, "Failed to derive swap keypair at index {}: {}", index, e); + Error::InvalidSecretKey + }) + } +} + +/// Derives the swap [`Keypair`] at hardened `index` from an already-derived +/// swap-domain master xpriv (Peerswap native primitive B7). +/// +/// Split out from [`WalletKeysManager::derive_swap_keypair`] as a generic-free, +/// `self`-free helper so the deterministic derivation can be exercised by unit +/// test vectors without constructing a full wallet/keys-manager. The instance +/// method adds error logging on top of this pure derivation. Secret material is +/// never logged here. +#[cfg(feature = "swaps")] +fn swap_keypair_from_master( + master: &Xpriv, index: u32, +) -> Result { + let secp = Secp256k1::new(); + let child = master.derive_priv(&secp, &[ChildNumber::Hardened { index }])?; + Ok(Keypair::from_secret_key(&secp, &child.private_key)) } impl NodeSigner for WalletKeysManager @@ -954,3 +1071,81 @@ where Ok(address.script_pubkey()) } } + +#[cfg(all(test, feature = "swaps"))] +mod swap_b7_tests { + //! Test vectors for the B7 dedicated swap-key derivation. + //! + //! These exercise the exact production derivation path used by + //! [`WalletKeysManager::derive_swap_keypair`] — namely + //! [`WalletKeysManager::derive_swap_master_xprv`] (the swaps-only hardened + //! BIP-32 domain) followed by [`swap_keypair_from_master`] — without having + //! to construct a full BDK-backed wallet/keys-manager. + + use super::swap_keypair_from_master; + use crate::types::KeysManager; + use bitcoin::secp256k1::{PublicKey, Secp256k1}; + use lightning::sign::KeysManager as LdkKeysManager; + + /// Fixed 32-byte seed used by every vector below. + const TEST_SEED: [u8; 32] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, 0x0f, 0x1e, 0x2d, 0x3c, 0x4b, 0x5a, 0x69, 0x78, 0x87, 0x96, 0xa5, 0xb4, 0xc3, 0xd2, + 0xe1, 0xf0, + ]; + + /// Derives the swap public key for `index` from `TEST_SEED` over the full + /// production path and returns it as a lowercase compressed-hex string. + fn swap_pubkey_hex(index: u32) -> String { + let master = KeysManager::derive_swap_master_xprv(&TEST_SEED); + let keypair = swap_keypair_from_master(&master, index).expect("derivation must succeed"); + keypair.public_key().to_string() + } + + #[test] + fn swap_keypair_matches_fixed_vector() { + // Fixed seed + index => fixed compressed public key. Regenerating this + // value would signal an (unintended) change to the swap derivation path. + assert_eq!( + swap_pubkey_hex(0), + "03d6c52bcef058703ff78e4d765f7b114ff5ad13f222596049b6a7bb66406bc6b6" + ); + assert_eq!( + swap_pubkey_hex(1), + "0203784b06423d07485e4378ebce2eca4c7db3caa15426d52715c2414f4b0cebd9" + ); + } + + #[test] + fn swap_keypair_is_deterministic() { + assert_eq!(swap_pubkey_hex(0), swap_pubkey_hex(0)); + // Distinct indices yield distinct keys. + assert_ne!(swap_pubkey_hex(0), swap_pubkey_hex(1)); + } + + #[test] + fn swap_key_differs_from_node_identity() { + // The node identity secret key is what LDK's KeysManager derives from the + // same seed. The swap key MUST come from a different (dedicated) path. + let ldk = LdkKeysManager::new(&TEST_SEED, 0, 0); + let node_secret = ldk.get_node_secret_key(); + let secp = Secp256k1::new(); + let node_pubkey = PublicKey::from_secret_key(&secp, &node_secret); + + let master = KeysManager::derive_swap_master_xprv(&TEST_SEED); + for index in 0..8u32 { + let swap_keypair = + swap_keypair_from_master(&master, index).expect("derivation must succeed"); + assert_ne!( + swap_keypair.secret_key(), + node_secret, + "swap secret at index {index} must never equal the node identity secret" + ); + assert_ne!( + swap_keypair.public_key(), + node_pubkey, + "swap pubkey at index {index} must never equal the node identity pubkey" + ); + } + } +} From f16ef0bcd223c68fac67b4eefc9e505cd36729bd Mon Sep 17 00:00:00 2001 From: tonible14012002 Date: Fri, 3 Jul 2026 17:51:44 +0700 Subject: [PATCH 8/9] feat(cycles): manual-route circular self-payment behind the `cycles` feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the cooperative cycle-balance primitive: `Node::send_along_route(route, amount_msat, payment_hash, preimage)` sends a spontaneous payment along a caller-supplied route back to self, recorded as `PaymentKind::Rebalance` (TLV type 12, ungated for record compatibility). The `PaymentClaimable` handler scopes the circular-payment and spontaneous-duplicate guards to exclude Rebalance records and claims the looped HTLC inline with the locally-held preimage, marking the single outbound record Succeeded — settlement is observed by polling the payment store, no user-facing event is emitted. Only the `Node` method is gated behind the new `cycles` feature; the default build is unaffected. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 4 ++ src/event.rs | 39 ++++++++++++++++++- src/lib.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++ src/payment/store.rs | 25 +++++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 708acd2789..70682d1649 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,10 @@ default = [] # Peerswap native primitives (B-series). Empty for now; gates all swap # additions so the default build is byte-for-byte unaffected. swaps = [] +# Cooperative cycle-balance primitive (manual-route circular self-payment). +# Gates the `Node::send_along_route` entry point; the `PaymentKind::Rebalance` +# variant and its `event.rs` claim path stay ungated for TLV compatibility. +cycles = [] [dependencies] lightning = { version = "0.1.0", features = ["std"] } diff --git a/src/event.rs b/src/event.rs index 22848bec1c..77f0066feb 100644 --- a/src/event.rs +++ b/src/event.rs @@ -573,7 +573,11 @@ where } => { let payment_id = PaymentId(payment_hash.0); if let Some(info) = self.payment_store.get(&payment_id) { - if info.direction == PaymentDirection::Outbound { + // Guard 1: refuse circular (self-loop) payments, EXCEPT for + // self-rebalance loops tagged as PaymentKind::Rebalance. Cross-node + // payments are never caught here (the remote recipient has no local + // Outbound record under the inbound hash). + if info.direction == PaymentDirection::Outbound && !info.is_rebalance() { log_info!( self.logger, "Refused inbound payment with ID {}: circular payments are unsupported.", @@ -594,8 +598,13 @@ where }; } + // Guard 2: refuse duplicate Succeeded payments and plain Spontaneous + // inbound payments. Self-rebalance loops (PaymentKind::Rebalance) must + // fall through here so we can claim the HTLC using our locally-held + // preimage and settle the loop. if info.status == PaymentStatus::Succeeded - || matches!(info.kind, PaymentKind::Spontaneous { .. }) + || (matches!(info.kind, PaymentKind::Spontaneous { .. }) + && !info.is_rebalance()) { log_info!( self.logger, @@ -679,6 +688,32 @@ where } } + // For self-rebalance loops the preimage is held locally in the + // Rebalance record. Claim immediately without inserting a new + // payment record (the outbound record already exists). + if let PaymentKind::Rebalance { preimage, .. } = info.kind { + log_info!( + self.logger, + "Claiming self-rebalance loop for payment hash {} of {}msat", + hex_utils::to_string(&payment_hash.0), + amount_msat, + ); + self.channel_manager.claim_funds(preimage); + + let update = PaymentDetailsUpdate { + status: Some(PaymentStatus::Succeeded), + amount_msat: Some(Some(amount_msat)), + ..PaymentDetailsUpdate::new(payment_id) + }; + match self.payment_store.update(&update) { + Ok(_) => return Ok(()), + Err(e) => { + log_error!(self.logger, "Failed to access payment store: {}", e); + return Err(ReplayEvent()); + }, + }; + } + // If this is known by the store but ChannelManager doesn't know the preimage, // the payment has been registered via `_for_hash` variants and needs to be manually claimed via // user interaction. diff --git a/src/lib.rs b/src/lib.rs index 5a780854e2..26c6a45847 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -964,6 +964,95 @@ impl Node { )) } + /// Sends a circular self-payment along a caller-supplied route (cooperative + /// cycle-balance primitive). + /// + /// The caller builds the exact [`Route`] (first hop = drain channel A, last hop = + /// fill channel B → self), generates a `preimage`/`payment_hash` pair (held + /// locally), and passes them here. The payment record is stored with + /// [`PaymentKind::Rebalance`] so the `event.rs` `PaymentClaimable` handler allows + /// the self-loop to settle instead of refusing it as a circular payment. + /// + /// Settlement is observed by polling [`Node::payment`] for the returned + /// [`PaymentId`]; no user-facing event is emitted for the loop. + /// + /// The raw [`ChannelManager`] is NOT exposed; this method is the sole entry + /// point for route-controlled self-pays. + /// + /// [`Route`]: lightning::routing::router::Route + /// [`PaymentKind::Rebalance`]: crate::payment::PaymentKind::Rebalance + /// [`ChannelManager`]: crate::types::ChannelManager + #[cfg(feature = "cycles")] + pub fn send_along_route( + &self, route: lightning::routing::router::Route, amount_msat: u64, + payment_hash: lightning_types::payment::PaymentHash, + preimage: lightning_types::payment::PaymentPreimage, + ) -> Result { + use lightning::ln::channelmanager::{RecipientOnionFields, RetryableSendFailure}; + + let rt_lock = self.runtime.read().unwrap(); + if rt_lock.is_none() { + return Err(Error::NotRunning); + } + + let payment_id = PaymentId(payment_hash.0); + + if let Some(existing) = self.payment_store.get(&payment_id) { + if existing.status == payment::PaymentStatus::Pending + || existing.status == payment::PaymentStatus::Succeeded + { + log_error!(self.logger, "Rebalance payment error: duplicate payment_id."); + return Err(Error::DuplicatePayment); + } + } + + let kind = payment::PaymentKind::Rebalance { hash: payment_hash, preimage }; + let payment_record = PaymentDetails::new( + payment_id, + kind, + Some(amount_msat), + None, + payment::PaymentDirection::Outbound, + payment::PaymentStatus::Pending, + ); + self.payment_store.insert(payment_record).map_err(|e| { + log_error!(self.logger, "Failed to insert rebalance payment record: {}", e); + e + })?; + + match self.channel_manager.send_payment_with_route( + route, + payment_hash, + RecipientOnionFields::spontaneous_empty(), + payment_id, + ) { + Ok(()) => { + log_info!( + self.logger, + "Initiated self-rebalance of {}msat (payment_id: {}).", + amount_msat, + payment_id, + ); + Ok(payment_id) + }, + Err(RetryableSendFailure::DuplicatePayment) => Err(Error::DuplicatePayment), + Err(e) => { + let update = payment::store::PaymentDetailsUpdate { + status: Some(payment::PaymentStatus::Failed), + ..payment::store::PaymentDetailsUpdate::new(payment_id) + }; + let _ = self.payment_store.update(&update); + log_error!( + self.logger, + "Self-rebalance send failed ({:?}) for payment_id {}.", + e, + payment_id, + ); + Err(Error::PaymentSendingFailed) + }, + } + } + /// Returns a payment handler allowing to send and receive on-chain payments. #[cfg(not(feature = "uniffi"))] pub fn onchain_payment(&self) -> OnchainPayment { diff --git a/src/payment/store.rs b/src/payment/store.rs index 75b2b1b2aa..0672b0f95e 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -61,6 +61,15 @@ impl PaymentDetails { .as_secs(); Self { id, kind, amount_msat, fee_paid_msat, direction, status, latest_update_timestamp } } + + /// Returns `true` if this is a circular self-rebalance payment sent along a + /// caller-supplied route. + /// + /// Used by the `event.rs` `PaymentClaimable` handler to allow the self-loop to + /// settle instead of being refused as a circular payment. + pub(crate) fn is_rebalance(&self) -> bool { + matches!(self.kind, PaymentKind::Rebalance { .. }) + } } impl Writeable for PaymentDetails { @@ -445,6 +454,18 @@ pub enum PaymentKind { /// The pre-image used by the payment. preimage: Option, }, + /// A circular self-rebalance payment sent along a caller-supplied route. + /// + /// The sender generates the preimage locally, sends the payment over a pinned + /// route (out-channel A → intermediaries → in-channel B → self), and claims it on + /// receipt. The `event.rs` `PaymentClaimable` guard falls through for this kind so + /// the loop is allowed to settle — all other self-loops are still refused. + Rebalance { + /// The payment hash, i.e., the hash of the `preimage`. + hash: PaymentHash, + /// The pre-image used by the payment (held locally by the initiating node). + preimage: PaymentPreimage, + }, } impl_writeable_tlv_based_enum!(PaymentKind, @@ -482,6 +503,10 @@ impl_writeable_tlv_based_enum!(PaymentKind, (2, preimage, option), (3, quantity, option), (4, secret, option), + }, + (12, Rebalance) => { + (0, hash, required), + (2, preimage, required), } ); From 630e5e13ff7720c8edc31bcb0468d20517521288 Mon Sep 17 00:00:00 2001 From: tonible14012002 Date: Fri, 3 Jul 2026 20:16:50 +0700 Subject: [PATCH 9/9] fix(cycles): make the rebalance loop actually receivable + pin the claim amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_along_route sent the final onion with RecipientOnionFields:: spontaneous_empty() and no keysend TLV, so lightning's final-hop parser (create_recv_pending_htlc_info) failed every looped HTLC with "We require payment_secrets" BEFORE PaymentClaimable could fire — the patched claim path was unreachable and no cycle could ever settle (fail-safe, but feature-dead). Fix: register the hash with the ChannelManager's STATELESS inbound-payment verifier (create_inbound_payment_for_hash, min_value_msat = amount) and send secret_only(payment_secret). No payment-store record is created, so the scoped circular guard still sees only the single Outbound Rebalance record; the secret never leaves the onion we build, so nobody else can construct a claimable HTLC for the hash. event.rs: belt-and-braces amount pin in the Rebalance claim — never claim_funds (= reveal the preimage) for less than the recorded loop amount; fail the HTLC backwards instead. Co-Authored-By: Claude Fable 5 --- src/event.rs | 15 +++++++++++++++ src/lib.rs | 25 ++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/event.rs b/src/event.rs index 77f0066feb..d119fffcd4 100644 --- a/src/event.rs +++ b/src/event.rs @@ -692,6 +692,21 @@ where // Rebalance record. Claim immediately without inserting a new // payment record (the outbound record already exists). if let PaymentKind::Rebalance { preimage, .. } = info.kind { + // Belt-and-braces amount pin: the stateless inbound registration + // already enforces `min_value_msat = amount` before this event can + // fire, but never reveal the preimage for less than the recorded + // loop amount. + if amount_msat < info.amount_msat.unwrap_or(0) { + log_error!( + self.logger, + "Refusing underpaying self-rebalance HTLC for payment hash {}: got {}msat, expected {}msat", + hex_utils::to_string(&payment_hash.0), + amount_msat, + info.amount_msat.unwrap_or(0), + ); + self.channel_manager.fail_htlc_backwards(&payment_hash); + return Ok(()); + } log_info!( self.logger, "Claiming self-rebalance loop for payment hash {} of {}msat", diff --git a/src/lib.rs b/src/lib.rs index 26c6a45847..22f3f4883a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1006,6 +1006,29 @@ impl Node { } } + // Register `payment_hash` with the ChannelManager's STATELESS inbound-payment + // verifier so the looped HTLC is receivable at the final hop. Without this the + // final onion payload carries neither a payment secret nor a keysend preimage + // and LDK fails it with "We require payment_secrets" BEFORE any + // `PaymentClaimable` fires — the loop could never settle. This creates NO + // payment-store record (unlike `Bolt11Payment::receive_for_hash`), so the + // scoped circular guard still sees only our single Outbound Rebalance record. + // `min_value_msat = amount_msat` means an underpaying HTLC never even surfaces + // a claimable event (no proof-of-payment leak); the secret only ever travels + // inside the onion we build, so no third party can construct a claimable HTLC + // for this hash. + let payment_secret = self + .channel_manager + .create_inbound_payment_for_hash(payment_hash, Some(amount_msat), 3600, None) + .map_err(|()| { + log_error!( + self.logger, + "Failed to register rebalance inbound payment for payment_id {}.", + payment_id, + ); + Error::PaymentSendingFailed + })?; + let kind = payment::PaymentKind::Rebalance { hash: payment_hash, preimage }; let payment_record = PaymentDetails::new( payment_id, @@ -1023,7 +1046,7 @@ impl Node { match self.channel_manager.send_payment_with_route( route, payment_hash, - RecipientOnionFields::spontaneous_empty(), + RecipientOnionFields::secret_only(payment_secret), payment_id, ) { Ok(()) => {